Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.
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

Expand Down
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions src/agent/posix-tool-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
2 changes: 1 addition & 1 deletion src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ 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 {
Expand Down Expand Up @@ -614,12 +617,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);
Expand Down
Loading
Loading