Skip to content

Commit 91fe7d4

Browse files
Do not ask the operator to approve a path the sandbox will reject (#908)
* 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. * Wrap long permission gate import to satisfy the formatter
1 parent f0cadc0 commit 91fe7d4

10 files changed

Lines changed: 189 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
8282
ellipsis. `read_file` of a tool-output URI is prompted only when truncation
8383
named one. A failed `read_file` shows the error on the collapsed transcript
8484
line.
85+
- Interactive path-arg tools that escape the workspace deny at authorize
86+
time instead of showing an Accept overlay that path-escape then rejects.
87+
Yolo / skip-permissions still allows those calls.
8588

8689
## [0.3.19] - 2026-09-10
8790

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ prompts. Pass `--no-auto` to start in ask-on-every-consequential-action mode
115115
files, …)
116116
- Opaque shell wrappers the policy cannot statically inspect (variable expansion
117117
or command substitution in a wrapper payload)
118-
- Paths outside the workspace, writes under the session state root, mutating MCP
118+
- Shell that references a path outside the workspace
119+
- Writes under the in-workspace session state root, mutating MCP
119120
tools, and unknown built-ins
120121

121122
### What auto hard-denies (use the file tools instead)

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ tool call
382382
- **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.
383383
- **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).
384384
- **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`.
385-
- **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.
385+
- **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.
386386
- **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()`.
387387
- **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.
388388

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL
183183
| **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) |
184184
| **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 |
185185

186-
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/<project-key>/…`, 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.
186+
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.
187187

188188
### Reasoning Effort
189189

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ the file path and parse details.
9898
- **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs.
9999
- **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).
100100
- **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.
101-
- Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt.
101+
- 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.
102102

103103
- **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).
104104
- **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.

src/agent/posix-tool-plugins.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,38 @@ describe("buildCorePosixToolPlugins", () => {
158158
}
159159
});
160160

161+
test("skipPermissions allows writing a path outside the workspace", async () => {
162+
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-skip-write-in-"));
163+
const outside = await mkdtemp(join(tmpdir(), "ic-posix-skip-write-out-"));
164+
try {
165+
const target = join(outside, "other.txt");
166+
const gate = createPermissionGate({
167+
approvals: [],
168+
interactive: false,
169+
skipPermissions: true,
170+
reactorGated: false,
171+
cwd,
172+
});
173+
const runner = createPosixTools({
174+
cwd,
175+
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
176+
});
177+
const result = await runner.run(
178+
{
179+
id: "out-write-1",
180+
name: "write_file",
181+
arguments: { path: target, content: "from-yolo" },
182+
},
183+
new AbortController().signal,
184+
);
185+
expect(result.isError).not.toBe(true);
186+
expect(await readFile(target, "utf8")).toBe("from-yolo");
187+
} finally {
188+
await rm(cwd, { recursive: true, force: true });
189+
await rm(outside, { recursive: true, force: true });
190+
}
191+
});
192+
161193
test("without skipPermissions, path-escape still blocks outside-workspace reads", async () => {
162194
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-bound-in-"));
163195
const outside = await mkdtemp(join(tmpdir(), "ic-posix-bound-out-"));

src/agent/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function buildHarnessFacts(
8787
"- 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.",
8888
]
8989
: [
90-
"- Dependency installs, paths outside the workspace, and session-state writes need operator approval.",
90+
"- 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.",
9191
]),
9292
"- Attached images are native multimodal input; inspect them directly unless file-level forensics are requested.",
9393
...(dynamicTools

src/permission/gate.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
safeWorktreeCommand,
2121
} from "./auto-shell-policy.js";
2222
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
23-
import { looksLikePath } from "../plugins/path-escape-plugin.js";
23+
import {
24+
looksLikePath,
25+
pathEscapeBlockReason,
26+
} from "../plugins/path-escape-plugin.js";
2427
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
2528
import { matchesPattern, escapeGlobLiteral } from "./matcher.js";
2629
import {
@@ -614,12 +617,30 @@ export function createPermissionGate(
614617
const subAgentIdentity = getSubAgentIdentity();
615618
const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd;
616619

620+
// Path-escape will hard-reject these at execution unless skipPermissions
621+
// (already returned allow above). Deny here rather than showing Accept for
622+
// a call that cannot succeed. Match the plugin stack: workers sandbox
623+
// against their own cwd and that cwd's worktree roots, not the session
624+
// listing (which excludes the session root itself).
625+
const escapeRoots =
626+
effectiveCwd === resolvedCwd
627+
? rootsProvider
628+
: createWorktreeRootsProvider(effectiveCwd);
629+
const escapeReason = pathEscapeBlockReason(
630+
call.arguments,
631+
effectiveCwd,
632+
escapeRoots,
633+
);
634+
if (escapeReason !== undefined) {
635+
return { kind: "deny", reason: escapeReason };
636+
}
637+
617638
const isRestrictedHere = bindRestrictedToProcessCwd(
618639
isRestricted,
619640
effectiveCwd,
620641
);
621-
// A call targeting a restricted path (outside the workspace, or a write
622-
// under the session state root) drops from allow to ask, so it never auto-allows on
642+
// A call targeting a restricted in-bounds path (a write under the session
643+
// state root) drops from allow to ask, so it never auto-allows on
623644

624645
// tier or shell-safety below.
625646
const restricted = callTargetsRestricted(call, isRestrictedHere);

0 commit comments

Comments
 (0)