- Status: Stable (ADR-0037 is Accepted)
- Canonical home: the contract for the engine-side
ToolRegistry+ dispatch inpackages/core(@relavium/core) and the host-injectedToolHostcapability seam it performs side effects through — workstream 1.T - Related: ../../decisions/0037-engine-tool-execution-boundary.md (the decision), ../../decisions/0029-tool-policy-hardening.md (the guardrails), built-in-tools.md (the tool catalog + the concrete result-bounding / config-only-param shapes), node-types.md (the
tool_config/agent_configblocks that carryparameters/input_mapping/output_mapping), mcp-integration.md (the MCP tool transport, 2.R), llm-provider-seam.md (the canonicalToolDeftheToolNormalizerlowers), ../contracts/sse-event-schema.md (agent:tool_call/agent:tool_resultpayloads + thetool_denied/tool_failedcodes), ../../standards/security-review.md (the untrusted-data + SSRF + secret rules), ../../standards/error-handling.md (the retryable/fatal mapping), ../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md (the siblingExecutionHostseam + the single event-translation point), ../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md (durable media handles)
This page is the one canonical home for the tool-execution contract — how the engine registers a tool, resolves it, assembles and validates its effective arguments, enforces ADR-0029's guardrails, performs the side effect through the host, bounds the model-facing result, and marks it untrusted. The why (the engine↔host boundary, the policy/mechanism split) lives in ADR-0037; the tool catalog (the thirteen built-ins, their return shapes, FS scope tiers, and the concrete result-bounding thresholds) lives in built-in-tools.md. This file is the dry reference its consumers (the 1.T registry, the 1.O AgentRunner, the 1.V AgentSession, each surface's ToolHost wiring) bind to. Where any other doc names a tool-dispatch rule it links here and never restates it.
The one rule that shapes everything here.
@relavium/corehas zero platform-specific imports (CLAUDE.md rule 5). Twelve of the thirteen built-in tools need filesystem / process / network / OS / media-store I/O the engine therefore cannot perform (onlyinvoke_agentis engine-internal). The engine owns policy + dispatch (pure); the mechanism is injected as aToolHost, the same purity seam asResolverCapabilities.readFile(1.L2) and the injected HTTP transport (ADR-0018). The TypeScript shapes below are the canonical interface the implementation mirrors.
A tool is registered as a ToolDef: its identity, an executable args validator (not just a JSON Schema), its guardrail/capability classification, and a pure dispatch that runs against the injected host.
/** Exact-match registry key. Built-ins use the fixed catalog ids; MCP ToolDefs are host-side assembled (2.R, ADR-0052). */
type ToolId = string; // e.g. 'read_file' | 'run_command' | 'mcp_{server}_{tool}' | 'plugin_{pkg}_{tool}'
interface ToolDef<Args = unknown, Result = unknown> {
readonly id: ToolId;
readonly source: 'builtin' | 'mcp' | 'plugin';
readonly description: string;
/**
* The EXECUTABLE args validator AND the source of truth for the parameter shape — a Zod schema's
* `parse` (Zod is already a `@relavium/core` dependency; no new dep). It validates the COMPLETE
* effective argument set (model + `input_mapping` + config-only), not just model args, and throws
* `ToolArgsInvalidError` on a miss. A bare `JSONSchema7` is data, not a validator — it cannot gate
* dispatch, so it is never the validation mechanism.
*/
parseArgs(raw: unknown): Args;
/**
* The LLM-VISIBLE projection of the parameter shape — and ONLY this — is what the `ToolNormalizer`
* (1.E) lowers to each provider's wire shape; a model can supply only these. Derived from the
* LLM-visible subset of the args schema (an in-house emitter or a hand-maintained projection),
* excluding every `configOnlyParams` entry. Typed `JsonSchema` (= `Readonly<Record<string, unknown>>`,
* exported by `@relavium/core`) — opaque data the engine carries, NOT the `@types/json-schema`
* `JSONSchema7` type (the engine never imports it).
*/
readonly llmVisibleParams: JsonSchema;
/**
* Names of CONFIG-PINNED parameters: their VALUES come from the node's `tool_config` / `agent_config`
* block ([node-types.md](node-types.md)) via `ctx.config`, are merged at dispatch (config wins), and
* NEVER appear in `llmVisibleParams` — a model argument can never override one (a pinned root path /
* base URL / timeout the model can't touch).
*/
readonly configOnlyParams?: readonly string[];
readonly policy: ToolPolicyClass;
/**
* Where this tool's guardrail target lives in the effective args, so the registry enforces the
* allowlist generically: a `spawnsProcess` tool returns the resolved `command` string the
* `allowedCommands` allowlist matches; an `http` egress tool returns the `url`. Omitted ⇒ no target
* (the generic allowlist check is skipped — e.g. a pre-approved `git_status` or an `os`/delegate tool).
* SECURITY: a `spawnsProcess` / `egress:'http'` tool that omits this has its allowlist check silently
* skipped (fail-open) — supply it, or pin the model-controlled args as config-only.
*/
readonly policyTarget?: (args: Args) => PolicyTarget; // { command?: string; url?: string; path?: string }
/**
* Pure dispatcher: validated+merged effective args in, the FULL result out. Performs side effects
* ONLY via `host`; never imports `node:*`/`fetch`. Threads `ctx.signal` (cooperative cancel). The
* model-facing bounding (built-in-tools.md) is applied by the pipeline AFTER `output_mapping`, never
* inside `dispatch`, so the full result reaches workflow state intact.
*/
dispatch(args: Args, host: ToolHost, ctx: ToolDispatchContext): Promise<Result>;
}
interface ToolPolicyClass {
/** Needs the FS scope tier — `read_file`, `write_file`, `list_directory`. */
readonly fsScoped: boolean;
/**
* The fs operation is a WRITE (`write_file`), not a read. `fsScoped` alone is `true` for reads AND
* writes alike, so it cannot tell `write_file` from `read_file`; this additive discriminator is the one
* the per-tool approval needs to gate writes ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) EA3)
* and the `fs-write` `ActionClass` [ADR-0041](../../decisions/0041-external-action-governance-seam.md) proposes —
* landed once, credited to both. Absent/false ⇒ a read-only fs tool (never governed by approval).
*/
readonly fsWrite?: boolean;
/** Spawns an OS process under the `allowedCommands` allowlist — `run_command`, `git_*`. */
readonly spawnsProcess: boolean;
/** Outbound egress — DISCRIMINATED by kind, because the three paths have different policies. */
readonly egress?: 'http' | 'search' | 'mcp';
/** An OS-integration action — `read_clipboard` (an un-jailed read of ambient, secret-bearing OS state) /
* `notify`. A governed action class (ADR-0057 §security review): gated by the interactive approval floor,
* never merely the advertise-filter. Absent/false ⇒ not an os action. */
readonly os?: boolean;
/** Requires a human-gate approval in an automated workflow before it may execute — `git_commit`. */
readonly requiresGateApproval: boolean;
}The canonical ToolDef that crosses the @relavium/llm seam to a provider is the leaner
{ name, description?, parameters } shape (llm-provider-seam.md); the
ToolNormalizer (1.E) lowers id → name and llmVisibleParams → parameters. configOnlyParams
never crosses the seam — that is the point.
The host (CLI / VS Code Node host, desktop Rust commands, Phase-2 cloud worker) injects a ToolHost.
Every method is the engine's only path to a side effect. Each capability is optional: a tool whose
capability is absent fails with a typed ToolUnavailableError (never a crash, never a platform import
sneaking into the engine) — exactly as an absent ResolverCapabilities.readFile fails the read_file
interpolation filter.
interface ToolHost {
readonly fs?: FsCapability; // file read/write/list, jailed to the active FS scope tier
readonly process?: ProcessCapability; // process spawn — host-resolved executable + base env; shell:false
readonly egress?: EgressCapability; // outbound HTTPS — shared SSRF primitive; host-resolved credential ref
readonly os?: OsCapability; // clipboard / native notification
readonly mcp?: McpCapability; // MCP client transport (ADR-0034; wired at 2.R)
readonly outputStore?: ToolOutputStore; // run-scoped bounded spill for over-ceiling results (below)
}
interface ProcessCapability {
/**
* Spawn an already-allowlist-checked command with **`shell: false`**. The HOST resolves the executable
* (platform PATH lookup — `npm`/`git` are unresolvable without it) and supplies the platform-minimal
* base environment, then merges ONLY the engine-declared extra vars under an audited allowlist —
* **never** the full ambient `process.env`. The engine cannot build a platform base env (it is
* platform-free), so env construction is the host's job; the engine supplies the *policy* (the
* allowlist-checked command + the declared vars) and the host enforces the FS scope tier + a
* CPU/mem/time budget. stdout/stderr/exit are untrusted.
*/
spawn(command: string, args: readonly string[], declaredEnv: Readonly<Record<string, string>>,
opts: SpawnOpts, signal?: AbortSignalLike): Promise<ProcessResult>;
}
interface ToolHostCallOptions {
readonly retainWork?: <T>(factory: () => Promise<T>) => Promise<T>;
}
interface McpCapability {
call(input: McpCallInput, signal?: AbortSignalLike, options?: ToolHostCallOptions): Promise<unknown>;
}
interface EgressCapability {
/**
* Perform an outbound HTTPS request the engine has ALREADY policy-checked (per egress kind, below).
* The host implementation MUST run the one shared SSRF range-primitive (block
* private/loopback/link-local/metadata/CGNAT; DNS-resolve + connect-by-validated-IP +
* per-hop-redirect-revalidate — security-review.md). A `credentialRef` is an OPAQUE secret-store
* reference (the `web_search` provider key, an auth header value) the host resolves and attaches
* INSIDE its trusted boundary — the ADR-0006/0018 key-reference pattern, so the raw secret never
* enters the engine / WebView. v1.0 bounding is applied post-hoc by the registry over the in-memory
* `EgressResponse.body`; a future `maxBytes` on `EgressRequest` (with 1.AE) enables source-side truncation.
* Ships feature-flag-OFF until the shared primitive lands at 1.AE.
*/
fetch(request: EgressRequest, signal?: AbortSignalLike, options?: ToolHostCallOptions): Promise<EgressResponse>;
// EgressRequest = { method, url, headers, body?, credentialRef?: string }
}
interface ToolOutputStore {
/** Spill an over-ceiling result to a run-scoped store; returns a handle + byte length, reclaimed on the
* run's terminal event (ADR-0036). A genuinely streamed huge source is bounded by its host capability
* at the boundary (returning an already-bounded payload); this spills the in-memory result a
* `dispatch` returned, so the engine hands the model a preview + the spill handle, not the full bytes. */
spill(text: string, limits: ToolResultLimits, signal?: AbortSignalLike): Promise<SpilledResult>;
}Sibling seams, one host.
ToolHostis a distinct seam from ADR-0036'sExecutionHost(persistence / clock / transport) and from 1.L2'sResolverCapabilities(theread_filefilter). The host wires all of them; in Phase-2 cloud the relocatedExecutionHostprovides theToolHost. None is folded into another — each stays minimal and auditable.
Both discovered MCP tools and mcp_call forward ctx.hostCallOptions separately from their
validated arguments. The trusted optional capability registers transitive transport work before
entry; it is never authored or serialized. The MCP lifetime contract
owns SDK request/handler custody and native-close acknowledgement.
The CLI process host rechecks cancellation at actual native spawn, after executable/cwd
resolution and argument/environment copying. Cancellation during those asynchronous steps
refuses entry; attaching a listener afterwards cannot observe an abort that already happened.
The existing command allowlist, jailed cwd, minimal environment and shell: false remain the
normal admission requirements.
For a child that was created, the host Promise settles only after the actual native close
event acknowledges the child and its stdio lifetime, including failed spawn. An error records
its fixed failure reason and starts process-group cleanup once; it does not certify closure or
remove timeout/abort listeners early. A repeated kill error cannot recursively start cleanup.
Cancellation retains precedence when an error also arrives. This follows the installed
Node child-process contract
and ADR-0103.
It establishes this host's local child lifetime, not complete workflow host departure or
termination of remote work.
At actual agent execution, the runner supplies transient hostCallOptions from its local
receipt-lifetime registration. The prepared dispatch context cannot supply this authority; actual dispatch explicitly
clears any runtime field when execution supplied no retainer.
http_request and web_search forward it as the separate third fetch parameter; it never
enters EgressRequest, tool arguments, provider request data or a durable event. The hook
owns raw work and does not authorize a new effect, money operation or fresh I/O after a
refusal. Standalone hosts may omit it.
A tool-local host retention refusal crosses registry error classification only as a fixed, private marker. The original opaque value remains in private identity storage and is restored before conversational tool recovery or diagnostic events. Pre-entry refusal invokes no raw factory; post-entry refusal keeps already-entered work owned. Genuine synchronous factory faults retain ordinary tool classification. The captured turn reports host-origin provenance and known usage without reflecting, serializing or treating that cause as a model-correctable failure.
The CLI text-egress host registers the complete credential/request operation before entry. The shared DB mechanism separately registers the raw DNS/connection/body operation before racing its bounded timeout. Already-aborted work does not enter, and a DNS answer arriving after cancellation cannot open a native connection. An entered operation remains owed until its actual Promise settles even when the caller has already received a timeout or terminal. Existing URL, IP, port, header and redirect policies remain the admission authority.
The native Node opener also registers its created request independently of the header
Promise. Headers, errors and destroy() do not acknowledge completion: both the native
ClientRequest and its owned IncomingMessage must emit close. A reusable pooled socket
is host infrastructure; this contract covers the request and its response, rather than
claiming that every pooled socket has physically closed. A trusted retention refusal keeps
its exact opaque identity without throwable reflection. If it happens after native entry,
the abandoned header Promise is still observed while the already-entered close lifetime
remains owned. Ordinary I/O faults retain their fixed, secret-free diagnostics.
These HTTP controls cover shipped agent/registry dispatch with both reference and SQLite host fences. They do not yet certify MCP's transitive sends/readers, media polling, all engine actors, final receipt health, public departure or acknowledged CLI teardown; those remain ADR-0103 implementation obligations.
registry.dispatch(toolCall, ctx) runs a fixed, pure pipeline. Security order matters: the
effective argument set is assembled before the guardrail checks, so a policy never runs on
half-populated args (a tool node has no model args — every value arrives via input_mapping — so a
policy that ran before assembly would inspect nothing and a command allowlist would be bypassed). The
host is touched once, in the middle.
- Reject a provider-executed call. If
toolCall.providerExecuted === true, reject immediately withToolPolicyError(reason: 'provider_executed')before id resolution — the engine never dispatches a provider-executedtool_call(content.ts; ADR-0030/0029). - Resolve by exact id, then check the grant. Look up
toolCall.nameby exact match — unknown / misspelled →UnknownToolErrorlisting the available ids (never fuzzy / nearest-name, ADR-0029). Then verify the id is inctx.grantedToolIds(the node's narrowed grant, 0029(b)); a registered-but-not-granted tool (e.g. a hallucinated / injectedtool_call) →ToolPolicyError. The registry holding aToolDefis not authorization to dispatch it. - Assemble the effective argument set. Start from the model-supplied args (for an agent tool-call) and/or
ctx.config.input_mapping(for atoolnode, where there are no model args), applyinput_mapping, then mergeconfigOnlyParamslast (config wins). - Validate the COMPLETE effective set via
tool.parseArgsand the secret-taint check —input_mapping/config-derived values are validated identically to model args; a secret-tainted value reaching a non-credential arg is rejected (0029(c)). A miss →ToolArgsInvalidError. - Enforce the guardrail policy on the EFFECTIVE args (the resolved command / URL is now the real value): exact
allowedCommands(+ opt-inallowedCommandGlobs, deny-all-empty); per egress kind (table below);git_commitrefused unlessctx.gateApproved. A denial →ToolPolicyError→ before any host call. 4b. Per-tool approval — the interactive consent gate (ADR-0057 EA3). Runs only whenctx.approvalis present (the interactive-approval regime — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a governed class:fsWrite, anegressof any kind, anosaction (read_clipboard/notify— the clipboard is an un-jailed exfiltration sink), or aspawnsProcesswith a model-controlledcommandtarget (so the pre-approvedgit_status, which exposes no command, is not gated — matching step 4). The engine consults the host-injectedctx.approval.confirmhook with a secret-free preview (resolved path / command / host — never a full URL/query, never a secret); just before invoking that hook, the engine'sconfirmDispatchemitsagent:approval_requested(viactx.approval.emitApprovalRequested) for every governed dispatch reaching this gate — whether the host then prompts a human (accept-edits / auto's protected-path fallback) or auto-decides (ask/plan deny, auto approve). Fail-closed: under an active regime a governed dispatch requires a decision — an absentconfirmhook ⇒ denied (ToolDeniedByUserError, reasonno_approval_hook), never silently allowed, so a wiring bug cannot letaskmode write. A reject ⇒ToolDeniedByUserError(reasonuser_rejected) carrying the existingtool_deniedcode (fatal, never retried); an abort while prompting routes to the cancellation path (cancel precedence). The host'sconfirmowns the mode policy (ask / plan / accept-edits / auto), the once/always cache, and the protected-paths rule — the engine stays mode-agnostic. This composes after step 4 and, when anActionGuard(action-guard-seam.md) is also injected, alongside it (the org governor'sdecide/commitand the user'sconfirmeach only further restrict). - Call the host capability (the single side effect), threading
ctx.signal. Absent capability →ToolUnavailableError; anAbortSignal-origin failure → the cancellation path (cancelled, nevertool_failed— preserves ADR-0036 cancel precedence); any other host throw →ToolExecutionError. A host capability MAY itself throw a typedToolDispatchErrorsubclass for a deterministic host-side denial (e.g. the CLIfsarm throwing atool_deniedwhen a path escapes the FS scope tier, orToolUnavailableErrorfor a read-only fail-close) — the registry passes anyToolDispatchErrorthrough verbatim, exactly as it does an engine-sideToolPolicyError, so such a denial is fatal (never burns the node-retry budget); only a raw host throw becomes the retryableToolExecutionError. 5b. Split off a media attachment, if the dispatch returned one (CR-50, ADR-0089 §1). A tool that answers with media returns a private-symbol envelope pairing an ordinary result with handle-onlyDurableMediaParts; the split happens HERE, at the dispatch boundary, so every step below sees the plain result and never the envelope. The parts leave onToolDispatchOutcome.mediaAttachments(branded untrusted) and the turn loop delivers them on one synthesizedusermessage after all of the response's tool results. Onlyread_mediaproduces one today. A journaled (tiered) dispatch returning an attachment is REFUSED: the journal round-trips JSON, so a replay would restore the result and deliver no media. - Apply
output_mappingto the FULL result → workflow state gets the real value, never the bounded preview. - Bound the model-facing result (§Result bounding and spill-to-file) from the result via
ctx.limits+ the hostoutputStore— over the ceiling the model gets a preview + a spill handle, the full result still flows tooutput_mapping. - Mark the result untrusted (§Untrusted-data taint) and hand the structured
tool_call/tool_resultdata + its taint/secret markers to the bus's single translation point (ADR-0036) foragent:tool_call/agent:tool_resultemission.
The durable effect bracket prepares a tiered call after approval and settles it
after model-facing bounding. Immediately before step 5, after any asynchronous approval and preparation,
the registry invokes the optional trusted ctx.beforeDispatch(toolId) synchronously for every actual
dispatch, including unjournaled tools. No asynchronous work may occur in that hook. It uses the resolved
registry id and does not run for a retained-result replay. A refusal proves no dispatch started: a prepared
claim is discarded, with a failed discard conservatively retaining the unresolved claim. This admission
check does not guard settlement of an effect that already started. Session hosts use it to enforce their
live durability latch independently of journal tier or cached effect identity.
Loop-correctable vs terminal.
UnknownToolErrorandToolArgsInvalidErrorare thrown by the registry; the agent loop (1.O) catches them and synthesizes a correctableisErrortool_result(from the secret-freeerror.message) so the model can fix its call, within a bounded correction budget it owns — escalating to a nodeErrorCodeonly when that budget is spent. AToolPolicyError— and, identically, aToolDeniedByUserError(the per-tool approval denial, ADR-0057) — is structurally fatal (tool_denied) and never fed back as a correctable result (re-asking a denied tool just burns budget), with one Step-14 exception: arecoverableSCOPE denial (media_scope_denied/ the fs pure scope-tier escape — refused before any side effect) IS fed back on therecoverToolFailuressurfaces (chat / Home / one-shotagent run) so the model can adapt to an in-bounds path (see therecoverablenote under the error taxonomy). See agent-runner.md §the failure ladder. AToolCancelledErrormaps tocancelledahead of all other classifications (cancel wins).
interface ToolDispatchContext {
readonly hostCallOptions?: ToolHostCallOptions | undefined; // trusted execution-only lifetime authority, never tool arguments
readonly nodeId: string;
readonly grantedToolIds: ReadonlySet<ToolId>; // the node's narrowed grant (0029(b)); dispatch refused outside it
readonly config: ToolNodeConfig; // resolved tool_config/agent_config block: configOnly VALUES + input/output_mapping (node-types.md)
readonly toolPolicy: ToolPolicy; // resolved allowedCommands/-Globs/-Domains (@relavium/shared)
readonly fsScope: FsScopeTier; // descriptive host-captured tier; cannot widen the host's immutable policy
readonly gateApproved: boolean; // registry gate floor; both production agent producers currently pass false
// Per-tool approval regime (ADR-0057 EA3). PRESENT ⇒ the interactive-approval (chat) path: a governed-class
// dispatch requires `approval.confirm`'s decision, and an absent confirm is fail-closed → denied. ABSENT ⇒
// the workflow author-trust path (unchanged). `confirm` is the host-injected ConfirmActionHook (the engine
// defines the interface + the invocation point, the host supplies the implementation — ADR-0037-clean).
readonly approval?: { readonly confirm?: ConfirmActionHook };
// The effective-arg keys whose resolved value is secret-tainted (ADR-0029(c)); the registry rejects
// any present one from tool args (re-applying the parse-time taint gate on the effective set).
// No live authored mapping producer exists. Absent means unverified external provenance, not clean;
// see Secret argument provenance below before activating a future mapped path.
readonly secretArgKeys?: ReadonlySet<string>;
readonly invokeAgent?: (nodeId: string, input: unknown) => Promise<unknown>; // engine delegate (invoke_agent); absent ⇒ ToolUnavailableError
readonly limits?: ToolResultLimits; // the result-bounding ceilings; absent ⇒ DEFAULT_TOOL_RESULT_LIMITS
readonly beforeDispatch?: (toolId: ToolId) => void; // synchronous trusted admission immediately before actual dispatch
readonly signal?: AbortSignalLike;
}ctx.config is populated by the AgentRunner (1.O) / DAG builder (1.M) from the node's resolved
config block (node-types.md tool_config / agent_config) — that is where the
config-only VALUES and the input_mapping / output_mapping come from. invokeAgent is an
engine-provided delegate, not a ToolHost capability — invoke_agent is pure orchestration
(dispatch another node by id), no platform I/O, no full router selection this phase.
ctx.approval.confirm is the host-injected interactive-consent hook the dispatch lifecycle's step 4b
consults — the same dependency-inversion pattern as the ToolHost (the engine defines the interface +
the invocation point; the host supplies the implementation, so ADR-0037's
tool-execution boundary holds). The host implementation owns the mode policy (ask / plan / accept-edits /
auto), the once/always cache, the protected-paths rule, and emitting agent:approval_requested — the
engine stays mode-agnostic and only asks "may this governed action proceed?" then honors the verdict. The
preview is secret-free, display-only.
/** Present on `ctx.approval` ⇒ the interactive-approval regime is active (the chat path). Absent ⇒ the
* workflow author-trust path (governed tools proceed under the step-4 floor, unchanged). */
interface ToolApprovalContext {
readonly confirm?: ConfirmActionHook; // ABSENT under an active regime ⇒ fail-closed deny (no_approval_hook)
}
type ConfirmActionHook = (
request: ToolApprovalRequest,
signal?: AbortSignalLike, // SHOULD be honored — an abort while prompting routes to the engine's cancel path
) => Promise<ToolApprovalDecision>;
interface ToolApprovalRequest {
readonly toolId: ToolId;
readonly action: ToolActionClass; // 'fs_write' | 'process' | 'egress' | 'os' (@relavium/shared TOOL_ACTION_CLASSES)
readonly preview: ToolActionPreview;
/**
* The same field selection as `preview`, UNSCRUBBED — for IN-PROCESS CLASSIFICATION ONLY (#91). A host that
* classifies the target (the CLI's auto-mode protected-path check) must read this, never `preview`, because
* the scrub can turn a protected path into an unprotected-looking one. Deliberately ABSENT from the
* `agent:approval_requested` event, and MUST NEVER be serialized, logged, persisted, or forwarded across the
* event / IPC / `--json` boundary. Optional so a hand-built request stays constructible; the engine always
* sets it.
*/
readonly unredactedPreview?: ToolActionPreview;
}
/** Secret-free (ENFORCED, not asserted), display-only — never a full URL/query, never a secret. */
interface ToolActionPreview {
readonly path?: string; // fs_write — the resolved target path, scrubbed WHOLE-STRING
readonly command?: string; // process — the resolved command string, scrubbed whole
readonly host?: string; // egress — the target host ONLY
}
type ToolApprovalDecision =
| { readonly outcome: 'approve' }
| { readonly outcome: 'reject'; readonly reason?: string }; // a secret-free, display-safe labelPreview redaction (#91). "Secret-free" is enforced by the registry, not asserted: every preview field is
scrubbed with the same redactSecretShapedText detector applied to agent:tool_call.toolInput, because the
ADR-0029(c) taint gate only covers declared secret-typed args — a model-placed credential in a
run_command arg or a write_file path is not one. Two consequences a consumer must know:
- A field may contain the literal
[redacted]marker and is NOT guaranteed to be a resolvable path/command/host. Never parse it. It is a display projection; the dispatch always runs against the real, unscrubbed target. - The scrub is whole-string, and a CLASSIFIER must not read it. Two detector patterns have value classes
containing
/, which cuts both ways: whole-string catches a credential whose value spans a separator, but one match can also swallow the rest of a path (./Access Token Backup/.ssh/authorized_keys→./Access Token [redacted]). No pattern gives both. So the two uses are split:previewis the display copy, andToolApprovalRequest.unredactedPreviewcarries the same field selection unscrubbed for in-process classification only. It is deliberately not the rawPolicyTarget— an egress entry carries the host and never the URL, whose query string is where a credential lives — and it is absent from theagent:approval_requestedevent, so it never crosses the event / IPC /--jsonboundary. - A
commandpreview can be shaped to blind the approver. The command is fully model-controlled and the detector's patterns are public, so an injected model can match one deliberately and be displayed as[redacted]— which reads as "we protected you" rather than "you cannot see this". Bounded byallowedCommands/allowedCommandGlobsbeing deny-all by default and by the fs/egress floors, but real; surfacing "this was redacted" to the prompt is an open follow-up.
A reject ⇒ ToolDeniedByUserError (reason user_rejected); a hook that throws a non-abort error ⇒ the same
fatal tool_denied (reason approval_error, fail-closed — consent could not be obtained, never the
retryable tool_failed a host-capability throw gets); an absent hook under an active regime ⇒
tool_denied (reason no_approval_hook). All three carry the existing non-retryable tool_denied
ErrorCode. The action class is derived from ToolPolicyClass (§The ToolDef): fsWrite ⇒ fs_write,
any egress ⇒ egress, a spawnsProcess with a model-controlled command target ⇒ process
(so the pre-approved git_status, which exposes no command, is not gated), and os ⇒ os
(read_clipboard — an un-jailed exfiltration sink — / notify).
The canonical guardrail home is security-review.md §Sandbox-and-tool-policy and ADR-0029; this table shows where each half runs — every engine-pure check is on the effective args (step 4).
| Guardrail | Engine-pure check (the policy) | Host mechanism |
|---|---|---|
Node tools: grant |
id ∈ ctx.grantedToolIds (exact); registered ≠ granted |
— |
run_command allowlist |
resolved command matched exactly vs allowedCommands; opt-in glob via allowedCommandGlobs; empty/absent ⇒ deny-all |
process.spawn, shell:false, FS scope tier + CPU/mem/time budget |
| Subprocess environment | engine supplies the declared vars + the policy | host resolves the executable + the platform-minimal base env, merges only allowlisted declared vars — never ambient process.env |
http_request (egress http) |
HTTPS-only, exact-FQDN allowedDomains, empty/absent ⇒ deny-all; an input_mapping-derived URL runs the same check (0029(d)) |
egress.fetch runs the shared SSRF primitive + connect-pinning (1.AE) |
web_search (egress search) |
untrusted-data-validated query (transits a query, not an FQDN allowlist — security-review.md §Prompt-injection) | egress.fetch to the configured provider; key via host-resolved credentialRef |
mcp_call (egress mcp) |
configured MCP server URL (not allowedDomains); secrets injected host-side |
mcp transport runs the same SSRF primitive (0029(d); 2.R) |
git_commit |
refused unless ctx.gateApproved; production agent contexts currently pass false, and chat confirmation cannot substitute |
process.spawn |
node tools: narrowing |
enforced at plan build when the host marks the grant complete (BuildRunPlanOptions.toolGrantsFinal), with resolveGrant as the dispatch floor for a directly-constructed executor (ADR-0094); narrow-only, 0029(b)/ADR-0023 |
— |
| secret args | Parser refusal covers declared secret references in text (transitive taint, 0029(c)); externally supplied secretArgKeys is checked on top-level effective-arg keys before approvals/effects (step 3). There is no live authored mapping producer; absent metadata is unverified. See secret argument provenance for the future mapped-path obligation |
host resolves an opaque credentialRef inside its trust boundary |
| FS scope tier | descriptive ctx.fsScope does not grant access |
fs.* enforces its host-captured immutable policy + rejects path traversal |
Built-in effective-argument bounds are canonical in built-in tools. Validation precedes policy targets, confirmation, effect preparation and host dispatch. Registry IDs and field-only refusal lists use shared UTF-16 code-unit ordering, independent of ambient locale.
The result a tool hands back to the model (which re-enters the next LlmRequest) is bounded —
distinct from the event outputSummary, which the registry bounding stage produces before bus stamping/delivery
(ADR-0036). Three rules:
- Bounding is model-facing only.
output_mappingruns on the full result (step 6) so workflow state holds the real value; only the model-facingtool_resultis replaced by a bounded preview + an explicit truncation marker + the spill path (step 7). - The full result is spilled to the host
outputStore; the model gets a preview + handle. v1.0 caveat: the bound is over the in-memory result thedispatchreturns — the seam'sFileRead.content/EgressResponse.body/ProcessResult.stdoutarestring, so the engine holds the full result transiently before spilling. A stream-and-incrementally-spill-at-the-boundary guarantee (the engine never holding the full bytes) requires a host-capability contract; it is not provided by this string-returning seam. Hosts own their source limits, and network availability depends on the surface/profile wiring. The shipping CLI wires egress inchat-read-write; itschat-read-onlyandworkflow-read-writeprofiles omit that arm. This does not claim streamed engine results or shipping workflow HTTP. The model-facing context-window protection (the stated purpose) holds today regardless. The output store is its ownToolHostcapability, so bounding does not depend on the optionalfscapability (aweb_search/ MCP host with nofsstill bounds). - Reclaimed at the terminal event. Spilled output is run-scoped and swept when the run reaches a terminal event (the ADR-0036 terminal-state sweep), like other run-scoped artifacts.
The ceiling is a byte/line bound (no token count — that needs a provider-specific tokenizer, which would break engine purity); concrete defaults and the marker shape are canonical in
built-in-tools.md. ToolResultLimits carries them into ctx.
Observability and journal-argument projections distinguish shared siblings from ancestor cycles.
Each walk removes a visited object when its branch finishes, including native/binary/media early
returns. A genuine ancestor back-reference becomes [cyclic]; repeated non-cyclic objects are
projected at every occurrence. The model-facing value and existing secret/media redaction policies
are unchanged. Sanitized effective arguments also feed the existing journal identity; preserving a
clean sibling avoids incorrectly treating distinct selections as a replay. This does not make the
projection a lossless canonical encoding or claim detection of arbitrary sensitive text.
Diagnostic truncation follows complete recognized-span redaction. toolInput applies
secret/media projection before clipping its string leaves; outputSummary recognizes
supported LF/TAB-split shapes before normalizing complete materialized text to one line,
then recognizes ordinary spans and bounds length. The
finite credential masking contract
owns supported spans, ambiguous continuation and public-prose boundaries. A closing
quote or URL authority separator beyond a preview boundary must not make a supported
credential shape unrecognizable. Raw model results, full spills and arbitrary unlabelled
sensitive text retain their existing content-sensitive handling.
Recorded replay results retain the existing journal contract;
this diagnostic correction does not rewrite historical content.
Every tool result is wrapped in the engine's branded untrusted marker at the registry boundary (the
same compile-time technique as the 0029(c) secret taint). Both halves of a dispatch outcome are branded:
toolResult, and — since CR-50 — mediaAttachments, the handle-only media a tool asks the engine to
deliver on a synthesized user message
(ADR-0089 §1). The second needs the brand more
than the first: the model chose that handle, and the message it rides is a user position, so a directive
painted into an image arrives as user speech. It is unwrapped once, at the single placement site, and the
message is fenced with an opening and a closing line telling the model to treat the attachment as reported
content — the same discipline the compaction summary uses. The 1.O message-assembly layer can then place
a tainted value only in a user / tool position, never system and never string-concatenated
into an instruction template — the type makes the unsafe path unrepresentable rather than relying on
per-call-site discipline (security-review.md §Prompt-injection, binding on 1.T/1.O/1.V). 1.T owns the marking; 1.O owns the placement. This also covers a value that flowed through 1.L2's resolveTemplate (which drops provenance), per the 1.O acceptance criteria.
| Tool | Capability / delegate | Guardrail |
|---|---|---|
read_file / write_file / list_directory |
host.fs |
FS scope tier (binary/media ⇒ durable handle, not inline base64 — below) |
run_command |
host.process |
exact allowedCommands + host-resolved executable + declared env, shell:false |
git_status |
host.process |
pre-approved git allowlist |
git_commit |
host.process |
human-gate approval required |
http_request |
host.egress (http) |
HTTPS-only + exact-FQDN allowedDomains + SSRF at the wired host; availability is profile-dependent |
web_search |
host.egress (search) |
provider key via host-resolved credentialRef; untrusted query |
read_clipboard / notify |
host.os |
— |
mcp_call |
host.mcp |
server URL runs the same SSRF primitive (0029(d); 2.R) |
invoke_agent |
ctx.invokeAgent (engine delegate) |
pure orchestration — no ToolHost I/O |
Binary / media results. A tool returning binary or media content (a read_file on a non-text file,
a media http_request body) returns a durable media handle (tool_result.media,
ADR-0031) — never inline base64 in
tool_result.result, which the ADR-0031 backstop rejects. The host writes the bytes to its media store
and returns the handle (the desktop CAS path of ADR-0032); this threads through the media sub-spine (1.AF) and is gated until it lands.
All typed, narrowed on a discriminant, secret-free (names a tool id / field, never an arg value, a resolved path, or a host stack — error-handling.md). The retryable/fatal split is owned by error-handling.md and the closed codes by sse-event-schema.md:
| Error | When | Run ErrorCode |
Class |
|---|---|---|---|
UnknownToolError |
id not an exact match | tool_failed |
fatal (loop-correctable first) |
ToolPolicyError |
a guardrail / grant denial — not_granted, provider_executed, command_not_allowed, domain_not_allowed, insecure_url, gate_required, media_scope_denied (the full ToolPolicyDenyReason union; media_scope_denied is read_media's scope-set denial, ADR-0044 §1) |
tool_denied |
fatal — EXCEPT media_scope_denied is recoverable (a Step-14 SCOPE denial; see the note below) |
ToolDeniedByUserError |
an interactive per-tool approval denial (ADR-0057 EA3) — user_rejected (rejected by the user / mode policy), no_approval_hook (fail-closed: a governed dispatch under an active regime with no confirm hook wired), approval_error (fail-closed: the hook threw a non-abort error, so consent could not be obtained) |
tool_denied |
fatal (never retried; not loop-correctable — re-asking re-prompts/re-denies, like ToolPolicyError) |
ToolArgsInvalidError |
effective args fail parseArgs / secret-taint |
validation |
fatal (loop-correctable first) |
ToolUnavailableError |
the required ToolHost capability is absent (host/config gap, not the model's fault) |
tool_unavailable |
fatal (names the tool + the unwired arm actionably — never a bare internal; EA1, ADR-0055) |
ToolExecutionError |
the host capability threw a non-cancel error (cause kept off the message, for logs) | tool_failed |
retryable (node budget) |
| (AbortSignal abort) | the run was cancelled mid-tool | cancelled |
fatal (cancel path, not tool_failed) |
recoverable (the base ToolDispatchError flag; default false). STRICTER than retryable (which lets a
fresh node-retry re-run the whole node): recoverable gates the WITHIN-TURN recovery on the
recoverToolFailures surfaces (the chat-read-write host —
ADR-0057: relavium chat / the Home / the one-shot
agent run; a WORKFLOW node never sets recoverToolFailures, so it ignores the flag — fail-fast always) — a
recoverable error is fed back to the model as an isError tool result so it can adapt (conversational recovery),
while everything else ends the turn. Two disjoint sources set it:
ToolExecutionError—trueonly for an idempotent read (nofs_write/process/egress/osside effect), stamped at the wrap fromgovernedAction; a governed / side-effecting failure staysfalse(fail-fast, so the model never re-attempts a non-idempotent side effect — a half-run command, a POST that may have landed).- A SCOPE denial (Step 14) — a
tool_deniedrefused BEFORE any side effect, so it is safe to feed back and the model can adapt to an in-bounds path (the floor still denies every attempt). Exactly two carry it: the engine'sToolPolicyError('media_scope_denied'), and the CLIfsarm's pure scope-tier escape (FsScopeDeniedError'the path escapes the allowed filesystem scope', thrown byassertInScopebefore any write; the only residual is an already-accepted parent-symlink-swap race whose sole preceding effect is an idempotent emptymkdir, never the payload write). Every OTHERtool_deniedstays fatal (false): aToolDeniedByUserError(user reject / fail-closed), the otherToolPolicyErrorguardrail reasons, an egress SSRF denial, and the fs confidentiality (secret-store read) / protected-path / symlink refusals — feeding those back would re-deny, risk a re-execution, or leak a probe oracle. (The scope-escape signal a model can gather across the boundedmaxToolCorrectionsrounds is a secret-free workspace-boundary probe, accepted — the reason interpolates no path and the same round-bounded feedback already existed for an idempotent not-found read.)
// Engine-pure: register the tools, inject one host. Both entry points share one instance.
const registry = createToolRegistry({
tools: BUILTIN_TOOLS, // the ToolDef catalog (built-in-tools.md); MCP ToolDefs are host-side assembled (2.R, ADR-0052)
host: toolHost, // the surface's ToolHost (Node fs/process/fetch; desktop Rust commands)
});
// Per dispatch, the node's grant + resolved config gate the call (ctx.grantedToolIds / ctx.config):
const result = await registry.dispatch(toolCall, ctx);createToolRegistry performs no I/O and reads no ambient state — a stub ToolHost makes the
whole registry unit-testable with zero real side effects (the engine coverage bar applies). The registry
holds every registered ToolDef, but a dispatch is authorized only by ctx.grantedToolIds, so adding a
tool to the catalog never widens what a given node may call. An MCP or media tool is an additive
ToolDef + ToolHost capability later (2.R / 1.AF), not a shape change here.
HTTP authority: approved design, unshipped — 2026-10-16. ADR-0112 was approved by the maintainer; its HTTP implementation remains staged in the Wave 3 plan. The separate secret argument provenance section describes the current boundary under ADR-0113. This combined heading remains for existing inbound links.
HTTP policy compares canonical hostname, not URL host-with-port. Reuse the shared HTTPS authority parser and one pure DNS normalizer/comparator for both request and allowlist. Permission is exact, with no suffix/subdomain match; existing HTTPS ports remain allowed, not 443-only. Native DNS/range, pinned connection, redirect/time/size floors still apply. Chat's global policy is defined only in config-spec.md; workflow policy and independent command/MCP/provider egress are not expanded here.
Chat HTTP authority uses one invocation-owned live narrowing view. Extend the existing beforeDispatch callback additively to receive tool ID and the existing PolicyTarget. Immediately after admitArgs, copy/freeze its known command/url/path fields once, before any await, and use that same target for approval/policy and post-prepare dispatch. The CLI session-host wrapper checks HTTP against current non-suspended authority at this post-approval/post-effect-prepare cut, then invokes the existing journal callback. Refusal discards any prepared effect under the existing protocol. A static host allowlist remains an earlier ceiling, never a replacement for this current check. Do not decorate shared fetch/search/MCP or invent a policyTarget function in model arguments. Existing callbacks ignoring new parameters remain compatible; migrate every invocation/forwarding site.
Live authority changes only at explicit rebuild/refresh, not file watching. Old and pending hosts share it, so failed replacement cannot restore a grant. A request admitted before narrowing may finish. Workflow domains remain separately owned and are not switched to global chat policy.
Current boundary — 2026-10-16.
ADR-0113
retains the optional external secretArgKeys interface. Nonempty tainted effective-argument
keys refuse before approvals/effects. Its absence is unverified external provenance,
not proof that effective arguments are clean. No live authored tool-input mapping producer
exists: current agent/session dispatches supply empty config, and model JSON has no
declared-input provenance. Neither this guard nor expression-context qualification detects
arbitrary credentials pasted into model/tool text. This is the documented #92 mapping
limitation, separate from the repaired expression-context exposure.
Activating any future mapped path requires fail-closed refusal of missing provenance and projection of taint after final model/mapping/config precedence to top-level effective argument keys, including parents of tainted nested paths, before approval persistence or effect dispatch. Marker serialization cannot clear provenance. That is a prerequisite for a future feature, not an implemented or exercised mapping producer in this wave.