From 474a37963945483d4b2db63d0ae6bf85b091340f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 10:00:04 -0700 Subject: [PATCH 1/2] Deny escaped path tools at authorize time Accept on an outside path still failed in the sandbox, so the overlay was dead. Deny at the gate instead; yolo still allows. --- CHANGELOG.md | 3 + README.md | 3 +- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 2 +- docs/PRODUCT.md | 2 +- src/agent/posix-tool-plugins.test.ts | 32 +++++++ src/agent/prompts.ts | 2 +- src/permission/gate.ts | 24 +++++- src/permission/permission.test.ts | 121 ++++++++++++++++++++++----- src/plugins/path-escape-plugin.ts | 22 +++++ 10 files changed, 186 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c30f94e5..1ee9b1d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ellipsis. `read_file` of a tool-output URI is prompted only when truncation named one. A failed `read_file` shows the error on the collapsed transcript line. +- Interactive path-arg tools that escape the workspace deny at authorize + time instead of showing an Accept overlay that path-escape then rejects. + Yolo / skip-permissions still allows those calls. ## [0.3.19] - 2026-09-10 diff --git a/README.md b/README.md index 05e6f8f86..558d9e608 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ prompts. Pass `--no-auto` to start in ask-on-every-consequential-action mode files, …) - Opaque shell wrappers the policy cannot statically inspect (variable expansion or command substitution in a wrapper payload) -- Paths outside the workspace, writes under the session state root, mutating MCP +- Shell that references a path outside the workspace +- Writes under the in-workspace session state root, mutating MCP tools, and unknown built-ins ### What auto hard-denies (use the file tools instead) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa947def2..021f215a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -382,7 +382,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: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise 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 and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask under auto mode. Under `--dangerously-skip-permissions` (forces this process) or `/yolo` (persists as the user-global default via `setSkipPermissions`), 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. Secret-guard path denies and authorization hard blocks still apply. 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: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise 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 `--dangerously-skip-permissions` (forces this process) or `/yolo` (persists as the user-global default via `setSkipPermissions`), 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. Secret-guard path denies and authorization hard blocks still apply. 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()`. - **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 d7ef75306..92dd447d2 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -183,7 +183,7 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL | **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) | | **ask** | Dependency installs / remote runners, recursive `rm`, force or uncontained git worktree add/remove/prune, sensitive-path references, paths outside the workspace (including through a symlink), opaque unparseable wrappers | -Unmatched shell auto-allows, including contained non-force `git worktree add`/`remove`/`prune` and read-only `list`. Writes under the session state root (`~/.corbits/projects//…`, and legacy in-repo `.agent-state` during dual-read), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. +Unmatched shell auto-allows, including contained non-force `git worktree add`/`remove`/`prune` and read-only `list`. Path-arg tools that escape the workspace are denied at authorize time (the same sandbox path-escape enforces at execution). Writes under the in-workspace session state root (legacy `.agent-state`), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. ### Reasoning Effort diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 58dd68298..276a0b9b4 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -98,7 +98,7 @@ the file path and parse details. - **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs. - **Still asks**: dependency installs and remote runners (npm/yarn/pnpm/bun, pip, cargo, go, brew, `npx`/`bunx`, …), recursive `rm`, force or uncontained git worktree add/remove/prune (contained non-force add/remove/prune and `list` auto-allow), shell that references sensitive paths, and opaque unparseable wrappers (variable expansion or command substitution). - **Wrapper peel**: `bash`/`sh`/`zsh -c`, `xargs`, and transparent prefixes (`env`, `nice`, `timeout`, …) are expanded so the same deny/ask rules see the inner payload. - - Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt. + - Path-arg tools that escape the workspace are denied at authorize time (yolo still allows them). Writes under the in-workspace session state root still ask; mutating MCP and unknown tools still prompt. Shell that targets an outside path still asks. - **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked unless `--dangerously-skip-permissions` / `/yolo` is on (secret-guard and authz hard denies still apply). - **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed; the result returned to the model (and shown to the operator) includes a bounded diff of the changed region — `write_file`, `edit_file`, `delete_file`, and each op inside `apply_patch` — so a follow-up `read_file` is never needed just to confirm an edit landed. A whole-file rewrite's diff is truncated (and says so) rather than blowing the result size cap. diff --git a/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index 2a6ae450f..5c4142715 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -158,6 +158,38 @@ describe("buildCorePosixToolPlugins", () => { } }); + test("skipPermissions allows writing a path outside the workspace", async () => { + const cwd = await mkdtemp(join(tmpdir(), "ic-posix-skip-write-in-")); + const outside = await mkdtemp(join(tmpdir(), "ic-posix-skip-write-out-")); + try { + const target = join(outside, "other.txt"); + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + cwd, + }); + const runner = createPosixTools({ + cwd, + plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }), + }); + const result = await runner.run( + { + id: "out-write-1", + name: "write_file", + arguments: { path: target, content: "from-yolo" }, + }, + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + expect(await readFile(target, "utf8")).toBe("from-yolo"); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + test("without skipPermissions, path-escape still blocks outside-workspace reads", async () => { const cwd = await mkdtemp(join(tmpdir(), "ic-posix-bound-in-")); const outside = await mkdtemp(join(tmpdir(), "ic-posix-bound-out-")); diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 44ec90bad..cbf4bc8e4 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -87,7 +87,7 @@ export function buildHarnessFacts( "- There is no turn budget. A tool-less reply without the structured report gets one incomplete-report nudge; if the next tool-less reply still omits the envelope, the harness salvages it. Otherwise, the run continues until completion, cancellation, an opt-in deadline, or a stall.", ] : [ - "- Dependency installs, paths outside the workspace, and session-state writes need operator approval.", + "- Dependency installs, shell that targets a path outside the workspace, and in-workspace session-state writes need operator approval. Path-arg tools that escape the workspace are denied.", ]), "- Attached images are native multimodal input; inspect them directly unless file-level forensics are requested.", ...(dynamicTools diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 514da28a9..a5e96fca7 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -20,7 +20,7 @@ import { safeWorktreeCommand, } from "./auto-shell-policy.js"; import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js"; -import { looksLikePath } from "../plugins/path-escape-plugin.js"; +import { looksLikePath, pathEscapeBlockReason } from "../plugins/path-escape-plugin.js"; import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js"; import { matchesPattern, escapeGlobLiteral } from "./matcher.js"; import { @@ -614,12 +614,30 @@ export function createPermissionGate( const subAgentIdentity = getSubAgentIdentity(); const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd; + // Path-escape will hard-reject these at execution unless skipPermissions + // (already returned allow above). Deny here rather than showing Accept for + // a call that cannot succeed. Match the plugin stack: workers sandbox + // against their own cwd and that cwd's worktree roots, not the session + // listing (which excludes the session root itself). + const escapeRoots = + effectiveCwd === resolvedCwd + ? rootsProvider + : createWorktreeRootsProvider(effectiveCwd); + const escapeReason = pathEscapeBlockReason( + call.arguments, + effectiveCwd, + escapeRoots, + ); + if (escapeReason !== undefined) { + return { kind: "deny", reason: escapeReason }; + } + const isRestrictedHere = bindRestrictedToProcessCwd( isRestricted, effectiveCwd, ); - // A call targeting a restricted path (outside the workspace, or a write - // under the session state root) drops from allow to ask, so it never auto-allows on + // A call targeting a restricted in-bounds path (a write under the session + // state root) drops from allow to ask, so it never auto-allows on // tier or shell-safety below. const restricted = callTargetsRestricted(call, isRestrictedHere); diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index 841de6b9f..f5ab455db 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -984,6 +984,74 @@ describe("gate denies compound commands with an authz-hard-blocked segment", () }); }); +describe("gate denies path tools path-escape will reject", () => { + // Same shape as authz-hard-blocked shell: a call the sandbox will fail at + // execution must deny at authorize time, not show an Accept overlay whose + // approval cannot succeed. skipPermissions (yolo) remains the live escape. + test("reactor-gated interactive write_file of an escaped path does not ask", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-escape-ask-in-")); + const outside = mkdtempSync(join(tmpdir(), "corbits-escape-ask-out-")); + const target = join(outside, "escape.ts"); + writeFileSync(target, ""); + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + cwd, + requestApproval: async () => { + asked++; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + reactorGated: true, + }); + const call: ToolCall = { + id: "c", + name: "write_file", + arguments: { path: target, content: "x" }, + }; + const authorized = await gate.authorizeCall(call); + expect(authorized.effect).toBe("deny"); + if (authorized.effect === "deny") { + expect(authorized.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); + const evaluated = await gate.evaluate(call); + expect(evaluated.allowed).toBe(false); + if (!evaluated.allowed) { + expect(evaluated.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); + }); + + test("skipPermissions still allows write_file of an escaped path", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-escape-yolo-in-")); + const outside = mkdtempSync(join(tmpdir(), "corbits-escape-yolo-out-")); + const target = join(outside, "escape.ts"); + writeFileSync(target, ""); + let asked = 0; + const gate = createPermissionGate({ + approvals: [], + cwd, + requestApproval: async () => { + asked++; + return { allow: false }; + }, + interactive: true, + skipPermissions: true, + reactorGated: true, + }); + const call: ToolCall = { + id: "c", + name: "write_file", + arguments: { path: target, content: "from-yolo" }, + }; + expect((await gate.authorizeCall(call)).effect).toBe("allow"); + expect((await gate.evaluate(call)).allowed).toBe(true); + expect(asked).toBe(0); + }); +}); + describe("createPermissionGate", () => { test("allow-tier tools pass without asking", async () => { let asked = 0; @@ -3138,7 +3206,7 @@ describe("read-only tools in auto mode", () => { expect(asked).toBe(0); }); - test("a read-only tool on a path outside the workspace still asks", async () => { + test("a read-only tool on a path outside the workspace is denied without asking", async () => { const outside = mkdtempSync(join(tmpdir(), "corbits-lsp-outside-")); const target = join(outside, "escape.ts"); writeFileSync(target, ""); @@ -3165,8 +3233,11 @@ describe("read-only tools in auto mode", () => { character: 1, }, }); - expect(verdict.allowed).toBe(true); - expect(asked).toBe(1); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); }); test("a read-only tool on a gitignored path is auto-allowed", async () => { @@ -3320,7 +3391,7 @@ describe("workspace-scoped autonomy in auto mode", () => { expect(asked).toBe(1); }); - test("a write outside the workspace and any registered worktree still asks", async () => { + test("a write outside the workspace and any registered worktree is denied without asking", async () => { const outside = mkdtempSync(join(tmpdir(), "corbits-outside-")); const target = join(outside, "escape.ts"); writeFileSync(target, ""); @@ -3342,11 +3413,14 @@ describe("workspace-scoped autonomy in auto mode", () => { name: "write_file", arguments: { path: target }, }); - expect(verdict.allowed).toBe(true); - expect(asked).toBe(1); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); }); - test("a symlink inside the workspace that points outside still asks", async () => { + test("a symlink inside the workspace that points outside is denied without asking", async () => { const base = mkdtempSync(join(tmpdir(), "corbits-symlink-")); const workspace = join(base, "ws"); const outside = join(base, "outside"); @@ -3372,11 +3446,14 @@ describe("workspace-scoped autonomy in auto mode", () => { name: "read_file", arguments: { path: join(workspace, "link", "secret.txt") }, }); - expect(verdict.allowed).toBe(true); - expect(asked).toBe(1); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); }); - test("a sibling directory sharing the workspace path as a prefix still asks", async () => { + test("a sibling directory sharing the workspace path as a prefix is denied without asking", async () => { const base = mkdtempSync(join(tmpdir(), "corbits-prefix-")); const workspace = join(base, "repo"); const evil = join(base, "repo-evil"); @@ -3400,8 +3477,11 @@ describe("workspace-scoped autonomy in auto mode", () => { name: "write_file", arguments: { path: join(evil, "payload.ts") }, }); - expect(verdict.allowed).toBe(true); - expect(asked).toBe(1); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); }); test("an unmatched shell command reading a path outside the workspace asks", async () => { @@ -3680,7 +3760,7 @@ describe("createWorktreeRootsProvider lazy re-discovery", () => { expect(asked).toBe(0); }); - test("a genuinely foreign path still asks for permission even after a refresh is triggered", async () => { + test("a genuinely foreign path is denied without asking even after a refresh is triggered", async () => { const repo = createRepo(); const outside = mkdtempSync(join(tmpdir(), "corbits-foreign-")); let asked = 0; @@ -3702,8 +3782,11 @@ describe("createWorktreeRootsProvider lazy re-discovery", () => { name: "write_file", arguments: { path: join(outside, "payload.ts") }, }); - expect(verdict.allowed).toBe(true); - expect(asked).toBe(1); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toMatch(/escapes working directory/); + } + expect(asked).toBe(0); }); test("a burst of foreign-path checks triggers at most one re-list", () => { @@ -4110,12 +4193,12 @@ describe("project-scoped grants match sub-agent worktree requests (CL-5662)", () // foreign cwd always asks regardless of any grant. write_file's subject is // the target path, not the agent's cwd, so it isolates the thing this test // actually checks: that an unscoped (no-cwd) grant matches irrespective of - // where the request originated. + // where the request originated. The second call uses a sibling worktree cwd + // so path-escape still treats the session-root target as in-bounds. test("session and provider-model grants still match a sub-agent request regardless of cwd", async () => { const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); - const { repo } = createRepoWithSiblingWorktree(); - const unrelated = mkdtempSync(join(tmpdir(), "corbits-unrelated-project-")); + const { repo, worktree } = createRepoWithSiblingWorktree(); const target = join(repo, "notes.md"); let asked = 0; const gate = createPermissionGate({ @@ -4147,7 +4230,7 @@ describe("project-scoped grants match sub-agent worktree requests (CL-5662)", () expect(asked).toBe(1); const second = await runWithSubAgentIdentity( - { description: "Worker", cwd: unrelated }, + { description: "Worker", cwd: worktree }, () => gate.evaluate({ id: "b", diff --git a/src/plugins/path-escape-plugin.ts b/src/plugins/path-escape-plugin.ts index fd83dc4bf..2e7b53a46 100644 --- a/src/plugins/path-escape-plugin.ts +++ b/src/plugins/path-escape-plugin.ts @@ -59,6 +59,10 @@ function escapeArgs( rootsProvider: RootsProvider, allowOutside: boolean, ): Record { + if (!allowOutside) { + const reason = pathEscapeBlockReason(args, cwd, rootsProvider); + if (reason !== undefined) throw new Error(reason); + } const out: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string" && looksLikePath(key)) { @@ -87,6 +91,24 @@ export function looksLikePath(key: string): boolean { ); } +// Same sandbox pathEscapePlugin enforces at execution. The permission gate +// consults this at authorize time so it can deny instead of asking for a call +// the plugin will reject after Accept. +export function pathEscapeBlockReason( + args: Record, + cwd: string, + rootsProvider: RootsProvider = () => [], +): string | undefined { + for (const [key, value] of Object.entries(args)) { + if (typeof value !== "string" || !looksLikePath(key)) continue; + if (isToolOutputLike(value) || isArchiveLike(value)) continue; + if (resolveWorkspacePath(cwd, value, rootsProvider) === undefined) { + return `Path escapes working directory: ${value}`; + } + } + return undefined; +} + function sanitizePath( value: string, cwd: string, From 389ce25cd4e8dc1017f09dec391be73abe3b9f61 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 20:53:47 -0700 Subject: [PATCH 2/2] Wrap long permission gate import to satisfy the formatter --- src/permission/gate.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/permission/gate.ts b/src/permission/gate.ts index a5e96fca7..e977ea166 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -20,7 +20,10 @@ import { safeWorktreeCommand, } from "./auto-shell-policy.js"; import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js"; -import { looksLikePath, pathEscapeBlockReason } from "../plugins/path-escape-plugin.js"; +import { + looksLikePath, + pathEscapeBlockReason, +} from "../plugins/path-escape-plugin.js"; import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js"; import { matchesPattern, escapeGlobLiteral } from "./matcher.js"; import {