Skip to content

Latest commit

 

History

History
577 lines (496 loc) · 51.9 KB

tool-registry.md

File metadata and controls

577 lines (496 loc) · 51.9 KB

Tool Registry & the ToolHost Seam

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/core has 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 (only invoke_agent is engine-internal). The engine owns policy + dispatch (pure); the mechanism is injected as a ToolHost, the same purity seam as ResolverCapabilities.readFile (1.L2) and the injected HTTP transport (ADR-0018). The TypeScript shapes below are the canonical interface the implementation mirrors.

The ToolDef — a registered tool

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 ToolHost capability seam

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. ToolHost is a distinct seam from ADR-0036's ExecutionHost (persistence / clock / transport) and from 1.L2's ResolverCapabilities (the read_file filter). The host wires all of them; in Phase-2 cloud the relocated ExecutionHost provides the ToolHost. 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.

Native process completion

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.

HTTP producer completion

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.

Resolution & the dispatch lifecycle

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.

  1. Reject a provider-executed call. If toolCall.providerExecuted === true, reject immediately with ToolPolicyError(reason: 'provider_executed') before id resolution — the engine never dispatches a provider-executed tool_call (content.ts; ADR-0030/0029).
  2. Resolve by exact id, then check the grant. Look up toolCall.name by exact match — unknown / misspelled → UnknownToolError listing the available ids (never fuzzy / nearest-name, ADR-0029). Then verify the id is in ctx.grantedToolIds (the node's narrowed grant, 0029(b)); a registered-but-not-granted tool (e.g. a hallucinated / injected tool_call) → ToolPolicyError. The registry holding a ToolDef is not authorization to dispatch it.
  3. Assemble the effective argument set. Start from the model-supplied args (for an agent tool-call) and/or ctx.config.input_mapping (for a tool node, where there are no model args), apply input_mapping, then merge configOnlyParams last (config wins).
  4. Validate the COMPLETE effective set via tool.parseArgs and 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.
  5. Enforce the guardrail policy on the EFFECTIVE args (the resolved command / URL is now the real value): exact allowedCommands (+ opt-in allowedCommandGlobs, deny-all-empty); per egress kind (table below); git_commit refused unless ctx.gateApproved. A denial → ToolPolicyError → before any host call. 4b. Per-tool approval — the interactive consent gate (ADR-0057 EA3). Runs only when ctx.approval is present (the interactive-approval regime — the chat path; absent ⇒ the workflow author-trust path, unchanged) and the dispatch is a governed class: fsWrite, an egress of any kind, an os action (read_clipboard / notify — the clipboard is an un-jailed exfiltration sink), or a spawnsProcess with a model-controlled command target (so the pre-approved git_status, which exposes no command, is not gated — matching step 4). The engine consults the host-injected ctx.approval.confirm hook with a secret-free preview (resolved path / command / host — never a full URL/query, never a secret); just before invoking that hook, the engine's confirmDispatch emits agent:approval_requested (via ctx.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 absent confirm hook ⇒ denied (ToolDeniedByUserError, reason no_approval_hook), never silently allowed, so a wiring bug cannot let ask mode write. A reject ⇒ ToolDeniedByUserError (reason user_rejected) carrying the existing tool_denied code (fatal, never retried); an abort while prompting routes to the cancellation path (cancel precedence). The host's confirm owns 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 an ActionGuard (action-guard-seam.md) is also injected, alongside it (the org governor's decide/commit and the user's confirm each only further restrict).
  6. Call the host capability (the single side effect), threading ctx.signal. Absent capability → ToolUnavailableError; an AbortSignal-origin failure → the cancellation path (cancelled, never tool_failed — preserves ADR-0036 cancel precedence); any other host throw → ToolExecutionError. A host capability MAY itself throw a typed ToolDispatchError subclass for a deterministic host-side denial (e.g. the CLI fs arm throwing a tool_denied when a path escapes the FS scope tier, or ToolUnavailableError for a read-only fail-close) — the registry passes any ToolDispatchError through verbatim, exactly as it does an engine-side ToolPolicyError, so such a denial is fatal (never burns the node-retry budget); only a raw host throw becomes the retryable ToolExecutionError. 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-only DurableMediaParts; the split happens HERE, at the dispatch boundary, so every step below sees the plain result and never the envelope. The parts leave on ToolDispatchOutcome.mediaAttachments (branded untrusted) and the turn loop delivers them on one synthesized user message after all of the response's tool results. Only read_media produces 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.
  7. Apply output_mapping to the FULL result → workflow state gets the real value, never the bounded preview.
  8. Bound the model-facing result (§Result bounding and spill-to-file) from the result via ctx.limits + the host outputStore — over the ceiling the model gets a preview + a spill handle, the full result still flows to output_mapping.
  9. Mark the result untrusted (§Untrusted-data taint) and hand the structured tool_call / tool_result data + its taint/secret markers to the bus's single translation point (ADR-0036) for agent:tool_call / agent:tool_result emission.

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. UnknownToolError and ToolArgsInvalidError are thrown by the registry; the agent loop (1.O) catches them and synthesizes a correctable isError tool_result (from the secret-free error.message) so the model can fix its call, within a bounded correction budget it owns — escalating to a node ErrorCode only when that budget is spent. A ToolPolicyError — and, identically, a ToolDeniedByUserError (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: a recoverable SCOPE denial (media_scope_denied / the fs pure scope-tier escape — refused before any side effect) IS fed back on the recoverToolFailures surfaces (chat / Home / one-shot agent run) so the model can adapt to an in-bounds path (see the recoverable note under the error taxonomy). See agent-runner.md §the failure ladder. A ToolCancelledError maps to cancelled ahead 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.

The per-tool approval seam (ConfirmActionHook, ADR-0057 EA3)

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 label

Preview 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: preview is the display copy, and ToolApprovalRequest.unredactedPreview carries the same field selection unscrubbed for in-process classification only. It is deliberately not the raw PolicyTarget — an egress entry carries the host and never the URL, whose query string is where a credential lives — and it is absent from the agent:approval_requested event, so it never crosses the event / IPC / --json boundary.
  • A command preview 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 by allowedCommands/allowedCommandGlobs being 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).

Guardrail enforcement (policy = engine-pure; mechanism = host)

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.

Result bounding and spill-to-file

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_mapping runs on the full result (step 6) so workflow state holds the real value; only the model-facing tool_result is 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 the dispatch returns — the seam's FileRead.content / EgressResponse.body / ProcessResult.stdout are string, 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 in chat-read-write; its chat-read-only and workflow-read-write profiles 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 own ToolHost capability, so bounding does not depend on the optional fs capability (a web_search / MCP host with no fs still 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.

Untrusted-data taint (1.T marks; 1.O places)

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.

Built-in tool → capability map

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.

Error taxonomy

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 — true only for an idempotent read (no fs_write / process / egress / os side effect), stamped at the wrap from governedAction; a governed / side-effecting failure stays false (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_denied refused 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's ToolPolicyError('media_scope_denied'), and the CLI fs arm's pure scope-tier escape (FsScopeDeniedError 'the path escapes the allowed filesystem scope', thrown by assertInScope before any write; the only residual is an already-accepted parent-symlink-swap race whose sole preceding effect is an idempotent empty mkdir, never the payload write). Every OTHER tool_denied stays fatal (false): a ToolDeniedByUserError (user reject / fail-closed), the other ToolPolicyError guardrail 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 bounded maxToolCorrections rounds 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.)

Instantiation

// 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.

Proposed Wave 3 HTTP authority and secret provenance

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.

Secret argument provenance

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.