diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dffa83ee4..61b086365 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -216,7 +216,7 @@ Compaction replaces older turns with a structured, workflow-aware summary rather ### Web Tools and Providers (`src/web/`) -`web_search` and `web_fetch` are always-on core tools (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`) — no plugin or API key required. `web_fetch` runs in-process on Bun's native `fetch()`: it resolves and rejects private/loopback/link-local targets before every request and again after each redirect hop (`src/tools/ssrf-guard.ts`), caps responses at 5 MB, and converts HTML to markdown by default (`text`/`markdown`/`html` formats). It opens with a browser User-Agent and retries once with an honest `corbits/` UA if the first attempt is rejected as bot traffic. `web_search` calls a keyless hosted MCP search provider (Exa's public endpoint by default, `https://mcp.exa.ai/mcp`; Parallel selectable via `CORBITS_WEB_SEARCH_PROVIDER=parallel`) through the same MCP client used for configured MCP servers (`src/mcp/client.ts`), with an optional bearer key via `CORBITS_WEB_SEARCH_API_KEY`. Both tools get their own permission classes — `webfetch` (approval scoped to the requested URL) and `websearch` (scoped to the tool as a whole) — distinct from shell and file classes, defaulting to ask-with-persistable-allow like any other consequential tool. SSRF protection lives in the tool boundary itself rather than being delegated to external provider infrastructure. +`web_search` and `web_fetch` are always-on core tools (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`) — no plugin or API key required. `web_fetch` runs in-process on Bun's native `fetch()`: it resolves and rejects private/loopback/link-local targets before every request and again after each redirect hop (`src/tools/ssrf-guard.ts`), caps responses at 5 MB, and converts HTML to markdown by default (`text`/`markdown`/`html` formats). It opens with a browser User-Agent and retries once with an honest `corbits/` UA if the first attempt is rejected as bot traffic. `web_search` calls a keyless hosted MCP search provider (Exa's public endpoint by default, `https://mcp.exa.ai/mcp`; Parallel selectable via `CORBITS_WEB_SEARCH_PROVIDER=parallel`) through the same MCP client used for configured MCP servers (`src/mcp/client.ts`), with an optional bearer key via `CORBITS_WEB_SEARCH_API_KEY`. Both tools get their own permission classes — `webfetch` (approval scoped to the requested URL) and `websearch` (scoped to the tool as a whole) — distinct from shell and file classes. They ask outside auto mode; auto mode allows them without a prompt. SSRF protection lives in the tool boundary itself rather than being delegated to external provider infrastructure. ### Director-Layer Tools (`src/agent/director.ts`) @@ -407,7 +407,7 @@ tool call - **classify** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) are tier `allow`; everything else is tier `ask`. Builds approval requests: shell yields one request for the full command the model asked to run (security still splits under the gate); file tools keyed on the target path; other tools keyed on tool name. - **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later). - **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, force or uncontained `git worktree` ops, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Contained non-force `git worktree add`/`remove`/`prune` and read-only `list` auto-allow (sibling destinations like `../corbits-dispatch-wts/…` included; absolute outside, `~`, globs, and credential basenames still ask). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`. -- **gate** — Evaluates a call in order: catastrophic authorization denial, `skipPermissions`, then the normal tier, grant, and auto policies. Catastrophic commands remain denied under every mode. Skip mode, enabled for the process by `--dangerously-skip-permissions` or `--yolo` and persisted to the active settings source by TUI `/yolo` (machine-wide only for the default user-global source, not an explicit `--config` source), allows the call before normal policy evaluation. Therefore `--auto --yolo` behaves as yolo: auto-mode file-mutation asks and denials do not run. Otherwise, `allow`-tier passes; for `ask`-tier, the gate checks persisted approvals and then requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `spawn_agent`, `wait_agents`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace on path-arg tools are denied at authorize time (the same sandbox path-escape enforces at execution, so the gate does not show an Accept overlay that cannot succeed). Writes under the in-workspace session state root (legacy `.agent-state`) still ask under auto mode. Under process-only `--dangerously-skip-permissions` / `--yolo` or TUI `/yolo` persisted in the active settings source (machine-wide only for the default user-global source), the gate auto-allows those same cases, and pre-gate sandboxes (path-escape, shell session cwd retention, `list_dir` / `delete_file` workspace bounds) honor `getSkipPermissions()` live so outside-workspace access is not hard-denied after the gate already allowed it — without rebuilding the plugin stack. Static secret-guard denies for path-keyed tools and authorization hard blocks still apply. Sensitive shell references require approval in normal and auto modes, but skip mode allows them without approval after catastrophic authorization checks. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. Newly granted scopes are appended in memory and persisted. +- **gate** — Evaluates a call in order: catastrophic authorization denial, `skipPermissions`, then the normal tier, grant, and auto policies. Catastrophic commands remain denied under every mode. Skip mode, enabled for the process by `--dangerously-skip-permissions` or `--yolo` and persisted to the active settings source by TUI `/yolo` (machine-wide only for the default user-global source, not an explicit `--config` source), allows the call before normal policy evaluation. Therefore `--auto --yolo` behaves as yolo: auto-mode file-mutation asks and denials do not run. Otherwise, `allow`-tier passes; for `ask`-tier, the gate checks persisted approvals and then requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `web_fetch`, `web_search`, `manage_tasks`, `spawn_agent`, `wait_agents`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace on path-arg tools are denied at authorize time (the same sandbox path-escape enforces at execution, so the gate does not show an Accept overlay that cannot succeed). Writes under the in-workspace session state root (legacy `.agent-state`) still ask under auto mode. Under process-only `--dangerously-skip-permissions` / `--yolo` or TUI `/yolo` persisted in the active settings source (machine-wide only for the default user-global source), the gate auto-allows those same cases, and pre-gate sandboxes (path-escape, shell session cwd retention, `list_dir` / `delete_file` workspace bounds) honor `getSkipPermissions()` live so outside-workspace access is not hard-denied after the gate already allowed it — without rebuilding the plugin stack. Static secret-guard denies for path-keyed tools and authorization hard blocks still apply. Sensitive shell references require approval in normal and auto modes, but skip mode allows them without approval after catastrophic authorization checks. Mutating MCP and unknown built-ins are not blanket-allowed outside skip. Newly granted scopes are appended in memory and persisted. - **Reactor-gated sessions (main session; `reactorGated: true`).** The gate's decision logic lives in one `decide()` used by both consumers: `evaluate()` (the middleware path below, still used by sub-agents) and `authorizeCall()`, which expresses the decision as the vendored reactor's before-tool authz effect (`src/permission/reactor-authorize.ts` bridges it into `env.authorize`). An `ask` there suspends the call as a reactor `PendingOperation` keyed by a correlationId (persisted through the context store's existing `pendingOperations`); `send()` settles as `suspended` and `src/session/approval-resume.ts` rebuilds the operator request from the approval snapshot, resolves it through the same `requestApproval` seam the TUI overlay uses, and delivers the decision to the reactor on the correlationId signal channel — an approved decision grants a one-shot bypass and the exact parked call re-dispatches; a rejected one answers it with an error result. `inFlight` occupancy owns idle rebuild: the TUI stays busy across the overlay and waits until the correlated resume is accepted (`message.received` / `message.correlated`) or a generation bump `settleAll`s the waiter. Delivery generation owns session identity: interrupt, `/clear`, and `/new` abort the outstanding overlay, skip minting a grant, drop the decision, and surface an operator notice rather than delivering into a rebuilt agent. Under reactor gating the middleware/MCP `gateToolCall` is an execution backstop, not a second copy of `env.authorize`: it consumes the `authorizeCall` verdict only when id, name, and arguments match, and does not re-decide. Deny still blocks and does not call `next`; an `ask` or `allow` skips the middleware prompt so an approved re-dispatch never re-asks. A reused `codex-proxy` id cannot apply an outer `shell` allow to an inner `run_shell` deny. Inner posix runs whose outer tool is not `run_shell` (Codex `apply_patch` proxy) never pass `env.authorize`, so `gateToolCall` decides on that cache miss and still blocks a deny. The headless denial and the stricter chained-command hard-deny are preserved as deny effects (upstream `block`s) decided inside the same `decide()`. - **Approval resume identity.** Before opening the operator gate, resume captures the session generation and agent/store pair and resolves the correlation exactly once through `ContextStore.load().pendingOperations` to one approval operation's `suspendedCall.id`. Missing or duplicate mappings produce no gate or delivery; store errors propagate with registration cleanup. Every decision path first checks captured history for an exact-call approval timeout, and operator decisions check again after the gate. While the overlay is open, resume watches history for that exact-call timeout and aborts the overlay signal so the gate auto-denies, occupancy (`inFlight`) unsticks, and no late decision is delivered. Identical tool names and arguments never establish identity. Cancellation during lookup cannot deliver a rejection. This suppresses observed exact timeouts, not all expired correlations: the reactor removes correlation state before the queued timeout result publishes, and expiration can also race the final history check or TUI delivery queue. Atomic stale-decision admission remains reactor-owned work tracked separately in CL-8000. - **Worker reactor ownership.** `workerPermissionGate` is a reactor-gated view over the parent's live permission gate: grants and policy are shared, not copied or toggled. Worker posix plugins and inherited MCP tools are bound to that view at worker start, so they take the reactor-gated `gateToolCall` path because the view reports `isReactorGated()` — they do not close over the parent's middleware-gated `isReactorGated()`. Deny still blocks; ask/allow skip the middleware prompt. `authorizeCall` on the view never emits `ask` — unresolved approvals become denials that name the permission subject, without invoking an approval callback or suspending, even with an interactive parent; the parent can obtain a grant and retry. Worker control-plane tools (`submit_result`, `ask_director`, and nested fleet verbs other than `spawn_agent`) allow without a parent grant. Authorization and tool execution run under the same async-local worker identity and cwd. Fleet authority remains an independent restriction, not an alternative permission grant. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fde3f24a0..80748f8da 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -176,7 +176,7 @@ Intent defaults: `intent=implement` → director `coder`; `explore` → `explore Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-auto` to start off, or `--auto` to force on). It is toggled only via those CLI flags — there is currently no in-session key bound to it. The permission gate reads the flag (`getAuto`/`setAuto` in `src/permission/gate.ts`) on the next tool call. `--dangerously-skip-permissions` and its `--yolo` alias force skip-permissions for this process only; skip-permissions wins when combined with auto, so `--auto --yolo` runs in yolo mode. `/yolo [on|off|toggle]` (bare `/yolo` also toggles) persists the skip-permissions setting to the active settings file and wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. The active source defaults to the user-global `~/.corbits/settings.json`, making that default machine-wide. Explicit `--config ` selects that file instead, so `/yolo` writes the custom file; a later ordinary TUI launch without the same `--config` uses the user-global source and does not modify the custom file. Secret-guard and authz still apply — and the active file itself is runtime-denylisted for path-keyed tools, reads and writes, even under skip-permissions, so the persisted skip cannot be silently leveraged; the startup notice remains as disclosure, not the enforcement. `loadConfig` tracks `skipPermissionsFromSettings` (true only when the effective value came from persisted settings, not either CLI flag) so `runTUI` can show a startup notice and `exec` can print an equivalent stderr warning for the otherwise-silent persisted setting. -When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOLS` and any `run_shell` that does not match the auto-shell policy. The policy (`autoShellRuleForCall` / `AUTO_SHELL_RULES` in `src/permission/auto-shell-policy.ts`) peels wrappers via `expandShellSubjects` (`bash`/`sh`/`zsh -c`, `xargs`, transparent prefixes), then applies: +When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOLS` — which also includes `web_fetch` and `web_search` — and any `run_shell` that does not match the auto-shell policy. With auto off, `web_fetch` and `web_search` still ask. The policy (`autoShellRuleForCall` / `AUTO_SHELL_RULES` in `src/permission/auto-shell-policy.ts`) peels wrappers via `expandShellSubjects` (`bash`/`sh`/`zsh -c`, `xargs`, transparent prefixes), then applies: | Effect | Categories | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/exec/runner.test.ts b/src/exec/runner.test.ts index 6ac653fdd..d01f86cc1 100644 --- a/src/exec/runner.test.ts +++ b/src/exec/runner.test.ts @@ -881,14 +881,14 @@ describe("exec permission prompt gating (CL-9002)", () => { }); const authorized = await gate.authorizeCall({ id: "h1", - name: "web_fetch", - arguments: { url: "https://example.com/docs", format: "markdown" }, + name: "write_file", + arguments: { path: "src/docs.md", content: "x" }, } as ToolCall); expect(authorized.effect).toBe("deny"); const evaluated = await gate.evaluate({ id: "h2", - name: "web_fetch", - arguments: { url: "https://example.com/other", format: "markdown" }, + name: "write_file", + arguments: { path: "src/other.md", content: "x" }, } as ToolCall); expect(evaluated.allowed).toBe(false); } finally { @@ -939,12 +939,12 @@ describe("exec permission prompt gating (CL-9002)", () => { }); const call = { id: "i1", - name: "web_fetch", - arguments: { url: "https://example.com/docs", format: "markdown" }, + name: "write_file", + arguments: { path: "src/docs.md", content: "x" }, } as ToolCall; const authorized = await gate.authorizeCall(call); expect(authorized.effect).toBe("ask"); expect(await gate.evaluate(call)).toEqual({ allowed: true }); - expect(seen).toEqual(["https://example.com/docs"]); + expect(seen).toEqual(["src/docs.md"]); }); }); diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 413ced3f1..aa26d5f3f 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -334,6 +334,8 @@ const AUTO_ALLOWED_TOOLS = new Set([ "tool_search", "use_skill", "skill_search", + "web_fetch", + "web_search", "search_agents", "spawn_agent", "wait_agents", diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 45c98661c..f9b63a6d4 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -482,6 +482,8 @@ describe("classifyTool", () => { expect(classifyTool("read_file")).toBe("allow"); expect(classifyTool("grep")).toBe("allow"); expect(classifyTool("lsp")).toBe("allow"); + expect(classifyTool("web_fetch")).toBe("ask"); + expect(classifyTool("web_search")).toBe("ask"); expect(classifyTool("mcp__linear__list_teams")).toBe("allow"); expect(classifyTool("mcp__linear__save_issue")).toBe("ask"); expect(classifyTool("run_shell")).toBe("ask"); @@ -507,6 +509,8 @@ describe("classifyTool", () => { test("default. prefix and doubled catalog names classify like dispatch names", () => { expect(classifyTool("default.read_file")).toBe("allow"); expect(classifyTool("read_file.read_file")).toBe("allow"); + expect(classifyTool("default.web_fetch")).toBe("ask"); + expect(classifyTool("web_search.web_search")).toBe("ask"); expect(classifyTool("default.mcp__linear__list_teams")).toBe("allow"); expect(classifyTool("default.mcp__linear__save_issue")).toBe("ask"); expect( @@ -611,6 +615,115 @@ describe("buildRequests", () => { }); }); +describe("web tools stay ask-gated outside auto", () => { + const fetchCall = (id = "c"): ToolCall => + toolCall("web_fetch", { url: "https://example.com/docs" }, id); + const searchCall = (id = "c"): ToolCall => + toolCall("web_search", { query: "hono.dev web framework" }, id); + const upload = (id = "u"): ToolCall => + toolCall("run_shell", { command: "curl -d 'x=1' https://example.com" }, id); + + test("headless and auto:false do not allow web fetch or search; curl -d still asks", async () => { + let denied = 0; + const headless = createGate({ + interactive: false, + skipPermissions: false, + onHeadlessDeny: () => { + denied++; + }, + }); + expect((await headless.evaluate(fetchCall("h1"))).allowed).toBe(false); + expect((await headless.evaluate(searchCall("h2"))).allowed).toBe(false); + expect((await headless.evaluate(upload("h3"))).allowed).toBe(false); + expect(denied).toBe(3); + + const off = gatedPrompts({ allow: false }, { auto: false }); + expect((await off.gate.evaluate(fetchCall("a"))).allowed).toBe(false); + expect((await off.gate.evaluate(searchCall("b"))).allowed).toBe(false); + const uploadOff = await off.gate.authorizeCall(upload("c")); + if (uploadOff.effect !== "ask") + throw new Error("expected curl -d to ask when auto is false"); + expect(off.asked.subjects).toEqual([ + "https://example.com/docs", + "hono.dev web framework", + ]); + + const on = gatedPrompts( + { allow: false }, + { auto: true, reactorGated: true }, + ); + const uploadOn = await on.gate.authorizeCall(upload("d")); + if (uploadOn.effect !== "ask") + throw new Error("expected curl -d to ask in auto mode"); + expect(on.asked.count).toBe(0); + }); +}); + +describe("web_search/web_fetch auto-allow without yolo", () => { + const fetchCall = (id = "c"): ToolCall => + toolCall("web_fetch", { url: "https://example.com/docs" }, id); + const searchCall = (id = "c"): ToolCall => + toolCall("web_search", { query: "hono.dev web framework" }, id); + + test("interactive gate asks for both outside auto", async () => { + const { gate, asked } = gatedPrompts({ allow: true }); + expect(await gate.evaluate(fetchCall("f"))).toEqual({ allowed: true }); + expect(await gate.evaluate(searchCall("s"))).toEqual({ allowed: true }); + expect(asked.subjects).toEqual([ + "https://example.com/docs", + "hono.dev web framework", + ]); + }); + + test("auto-mode gate allows both with no prompt", async () => { + const { gate, asked } = gatedPrompts({ allow: true }, { auto: true }); + expect(await gate.evaluate(fetchCall())).toEqual({ allowed: true }); + expect(await gate.evaluate(searchCall())).toEqual({ allowed: true }); + expect(asked.count).toBe(0); + }); + + test("headless gate denies both when auto is unset", async () => { + let denied = 0; + const gate = createGate({ + interactive: false, + onHeadlessDeny: () => { + denied++; + }, + }); + expect((await gate.evaluate(fetchCall())).allowed).toBe(false); + expect((await gate.evaluate(searchCall())).allowed).toBe(false); + expect(denied).toBe(2); + }); + + test("reactor path asks for both when auto is unset", async () => { + const { gate, asked } = gatedPrompts( + { allow: true }, + { reactorGated: true }, + ); + const fetchVerdict = await gate.authorizeCall(fetchCall()); + const searchVerdict = await gate.authorizeCall(searchCall("s")); + if (fetchVerdict.effect !== "ask" || searchVerdict.effect !== "ask") + throw new Error("expected web tools to suspend outside auto"); + expect(fetchVerdict.request.subject).toBe("https://example.com/docs"); + expect(searchVerdict.request.subject).toBe("hono.dev web framework"); + expect(asked.count).toBe(0); + }); + + test("shell installs still ask: no prompt broadening", async () => { + const { gate, asked } = gatedPrompts( + { allow: true }, + { reactorGated: true }, + ); + const verdict = await gate.authorizeCall(shellCall("npm install express")); + if (verdict.effect !== "ask") + throw new Error("expected npm install to still suspend for approval"); + expect(asked.count).toBe(0); + const headless = createGate({ interactive: false }); + const denied = await headless.evaluate(shellCall("npm install express")); + expect(denied.allowed).toBe(false); + }); +}); + describe("gate authorizes shell chains as one block with per-segment security", () => { test("declining a later segment blocks the call even when the first segment is approved", async () => { const full = "(cd packages/shared && rm -rf dist)"; @@ -1550,20 +1663,20 @@ describe("createPermissionGate", () => { // The reactor retries a denied ask-tier call with a fresh tool_call.id; the // retry must deny with the identical cached reason, or the loop never settles. - test("headless denies a same-URL web_fetch retry with the identical reason", async () => { + test("headless denies a same-path write_file retry with the identical reason", async () => { const gate = createGate({ interactive: false, }); - const fetch = (id: string, url: string) => + const write = (id: string, path: string) => gate.evaluate({ id, - name: "web_fetch", - arguments: { url, format: "markdown" }, + name: "write_file", + arguments: { path, content: "x" }, }); - const first = await fetch("call_0", "https://example.com/docs"); - const retry = await fetch("call_1", "https://example.com/docs"); + const first = await write("call_0", "src/docs.md"); + const retry = await write("call_1", "src/docs.md"); if (first.allowed || retry.allowed) - throw new Error("expected both web_fetch calls denied"); + throw new Error("expected both write_file calls denied"); expect(retry.reason).toBe(first.reason); }); @@ -1574,25 +1687,25 @@ describe("createPermissionGate", () => { { allow: false }, { reactorGated: true }, ); - const args = { url: "https://example.com/docs", format: "markdown" }; - const first = await gate.authorizeCall(toolCall("web_fetch", args)); + const args = { path: "src/docs.md", content: "x" }; + const first = await gate.authorizeCall(toolCall("write_file", args)); if (first.effect !== "ask") throw new Error("expected the first call to suspend for approval"); const outcome = await gate.resolveSuspended(first.request); expect(outcome?.allow).toBe(false); expect(asked.count).toBe(1); - const retry = await gate.authorizeCall(toolCall("web_fetch", args)); + const retry = await gate.authorizeCall(toolCall("write_file", args)); if (retry.effect !== "deny") throw new Error("expected the retry denied from denial memory"); const { gate: middleware, asked: middlewareAsked } = gatedPrompts({ allow: false, }); - const verdict = await middleware.evaluate(toolCall("web_fetch", args)); + const verdict = await middleware.evaluate(toolCall("write_file", args)); if (verdict.allowed) throw new Error("expected the middleware call declined"); expect(middlewareAsked.count).toBe(1); expect(retry.reason).toBe(verdict.reason); - const retryAgain = await gate.authorizeCall(toolCall("web_fetch", args)); + const retryAgain = await gate.authorizeCall(toolCall("write_file", args)); if (retryAgain.effect !== "deny") throw new Error("expected the second retry denied"); expect(retryAgain.reason).toBe(retry.reason); @@ -1600,21 +1713,23 @@ describe("createPermissionGate", () => { }); test("aliased-name decline is cached: default. prefix retry denies without re-asking", async () => { - const args = { url: "https://example.com/docs", format: "markdown" }; + const args = { path: "src/docs.md", content: "x" }; const { gate: middleware, asked } = gatedPrompts({ allow: false }); const first = await middleware.evaluate( - toolCall("default.web_fetch", args), + toolCall("default.write_file", args), ); if (first.allowed) throw new Error("expected the aliased call declined"); expect(asked.count).toBe(1); const aliasedRetry = await middleware.evaluate( - toolCall("default.web_fetch", args), + toolCall("default.write_file", args), ); if (aliasedRetry.allowed) throw new Error("expected the aliased retry denied from denial memory"); expect(asked.count).toBe(1); expect(aliasedRetry.reason).toBe(first.reason); - const catalogRetry = await middleware.evaluate(toolCall("web_fetch", args)); + const catalogRetry = await middleware.evaluate( + toolCall("write_file", args), + ); if (catalogRetry.allowed) throw new Error("expected the catalog retry denied from denial memory"); expect(asked.count).toBe(1); @@ -1625,7 +1740,7 @@ describe("createPermissionGate", () => { { reactorGated: true }, ); const suspended = await reactor.authorizeCall( - toolCall("default.web_fetch", args), + toolCall("default.write_file", args), ); if (suspended.effect !== "ask") throw new Error("expected the aliased call to suspend for approval"); @@ -1633,7 +1748,7 @@ describe("createPermissionGate", () => { expect(outcome?.allow).toBe(false); expect(reactorAsked.count).toBe(1); const retry = await reactor.authorizeCall( - toolCall("default.web_fetch", args), + toolCall("default.write_file", args), ); if (retry.effect !== "deny") throw new Error( @@ -1654,14 +1769,14 @@ describe("createPermissionGate", () => { return { allow: false, message: APPROVAL_TIMEOUT_RESULT_TEXT }; }, }); - const args = { url: "https://example.com/docs", format: "markdown" }; - const first = await gate.authorizeCall(toolCall("web_fetch", args)); + const args = { path: "src/docs.md", content: "x" }; + const first = await gate.authorizeCall(toolCall("write_file", args)); if (first.effect !== "ask") throw new Error("expected the first call to suspend for approval"); const outcome = await gate.resolveSuspended(first.request); expect(outcome?.allow).toBe(false); expect(asked).toBe(1); - const retry = await gate.authorizeCall(toolCall("web_fetch", args)); + const retry = await gate.authorizeCall(toolCall("write_file", args)); if (retry.effect !== "ask") throw new Error("expected the retry to re-ask after a timeout"); expect(asked).toBe(1); @@ -1671,7 +1786,7 @@ describe("createPermissionGate", () => { // populates denial memory. Timeouts, aborts, and missing outcomes are never // cached — no decision was made, so a fresh-id retry re-asks. test("middleware-path timeout/abort/missing outcomes are not cached: retry re-asks", async () => { - const args = { url: "https://example.com/docs", format: "markdown" }; + const args = { path: "src/docs.md", content: "x" }; const outcomes: { name: string; outcome: ApprovalOutcome | undefined }[] = [ { name: "timeout", @@ -1694,35 +1809,35 @@ describe("createPermissionGate", () => { return outcome as ApprovalOutcome; }, }); - const first = await gate.evaluate(toolCall("web_fetch", args)); + const first = await gate.evaluate(toolCall("write_file", args)); if (first.allowed) throw new Error(`expected the first ${name} call denied`); - const retry = await gate.evaluate(toolCall("web_fetch", args)); + const retry = await gate.evaluate(toolCall("write_file", args)); if (retry.allowed) throw new Error(`expected the ${name} retry denied after re-asking`); expect(asked).toBe(2); } }); - // Distinct URLs deny independently; reset() clears denial memory so the + // Distinct paths deny independently; reset() clears denial memory so the // next turn re-denies with no stale state. - test("headless denies distinct web_fetch URLs independently; reset clears denials", async () => { + test("headless denies distinct write_file paths independently; reset clears denials", async () => { const gate = createGate({ interactive: false, }); - const fetch = (id: string, url: string) => + const write = (id: string, path: string) => gate.evaluate({ id, - name: "web_fetch", - arguments: { url, format: "markdown" }, + name: "write_file", + arguments: { path, content: "x" }, }); - const first = await fetch("call_0", "https://example.com/first"); - const other = await fetch("call_1", "https://example.com/second"); + const first = await write("call_0", "src/first.md"); + const other = await write("call_1", "src/second.md"); if (first.allowed || other.allowed) - throw new Error("expected both web_fetch calls denied"); + throw new Error("expected both write_file calls denied"); gate.reset(); - const again = await fetch("call_2", "https://example.com/first"); - if (again.allowed) throw new Error("expected the re-fetch denied"); + const again = await write("call_2", "src/first.md"); + if (again.allowed) throw new Error("expected the re-write denied"); expect(again.reason).toBe(first.reason); });