Skip to content

Commit af62702

Browse files
Merge pull request #1037 from corbitsdev/cl-7950-unify-permission-verdict-path-and-single-owner-shell-policy
Unify permission verdict path with a mode-invariant catastrophic deny
2 parents 376ed98 + 78c32a4 commit af62702

16 files changed

Lines changed: 203 additions & 574 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,9 @@ Tool middleware applied over `createPosixTools`, in this order:
364364
tool call
365365
→ pathEscapePlugin (resolve + sandbox paths)
366366
→ secretGuardPlugin (hard-deny path-keyed secret files)
367-
→ authzPlugin (deny catastrophic commands)
368-
→ permissionPlugin (tiered operator approval)
369-
→ verifyPlugin (post-write/edit verification)
367+
→ permissionPlugin (tiered operator approval; hard-denies catastrophic
368+
shell commands at the top of its verdict path)
369+
→ verifyPlugin (post-write/edit verification)
370370
→ actual tool execution
371371
```
372372

@@ -377,7 +377,7 @@ tool call
377377
- **Evidence archive** (`evidence-archive-search-plugin.ts`, `evidence-archive-path-guard.ts`) — Primary-session compaction evidence is a first-class search/read surface on `search_files` / `read_file` / `grep` via `archive:///` refs. Dump paths (`evidence-archive/`, `tool-output/archive-*`) stay blocked so the on-disk sidecar is not the retrieval API. Blob keys reject `/` so they cannot nest under `tool-output`.
378378
- **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched).
379379
- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output.
380-
- **Authorization** (`run-shell-authz.ts`, wired by `authz-plugin.ts`) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The permission gate’s shell auto-allow path consults the same policy so it never pre-approves a command authz would reject.
380+
- **Authorization** (`run-shell-authz.ts`, enforced by the permission gate) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The gate hard-denies these at the top of its verdict path — before auto-allow, prompting, grants, and skipPermissions — so no mode or stored grant can admit them.
381381
- **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate.
382382
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): no built-in default timeout (optional per-call or `settings.shell.timeoutMs`; `maxTimeoutMs` clamps only a resolved timeout), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout, abort, and plugin dispose (live children tracked in the plugin and reaped by `posixTools.dispose`), and `background: true` — the call returns a `shell_id` at once (registry in `src/shell/background-shell.ts`), the process group keeps running past the turn, completion is delivered on a later turn via `buildShellBackgroundMessage`, and `shell_collect` retrieves or cancels (schema advertised by `advertiseShellGuardTimeout`; evaluated by the permission chain at start time like any shell call). Also applies a 10s wall-clock budget to `grep`/`search_files`. Ripgrep detached spawns are not tracked.
383383
- **Read File Guard** (`read-file-guard-plugin.ts`) — Corbits Code-only short-circuit for `read_file` on real filesystem paths and configured `tool-output://` URIs (interchange stays unpatched): streaming reads that never decode the whole file in one pass, caps model-facing output at 50KB, defaults to 2000 lines, truncates long lines with recovery hints, samples the first chunk to reject binary, and stops at an 8MB scan ceiling. Emits `offset` continuation notices so the model can page without losing file or spill content on disk.

‎docs/IMPLEMENTATION.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,9 @@ src/
121121
evidence-archive-path-guard.ts Block dump-path reads of the archive sidecar
122122
tool-output-uri-plugin.ts Normalize read_file tool-output URIs
123123
secret-guard-plugin.ts Hard-deny path-keyed secret files
124-
authz-plugin.ts Catastrophic command blocking (thin wrapper)
125-
permission-plugin.ts Tiered operator approval
124+
permission-plugin.ts Tiered operator approval (owns catastrophic shell deny)
126125
shell/
127-
run-shell-authz.ts Shared run_shell deny policy (authz + permission)
126+
run-shell-authz.ts Shared run_shell deny policy (gate-enforced)
128127
background-shell.ts Background run_shell registry (start/collect/cancel/disposeAll)
129128
verify-plugin.ts Write/edit verification (per-path lock)
130129
file-mutation-lock.ts Serialize mutations per file for verify

‎src/agent/codex-read-raw-file.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* `applyOp` calls this directly from outside the posixTools middleware chain
55
* — no ToolPlugin ever sees `op.path` here, unlike the write leg which still
6-
* goes through the full pathEscapePlugin / secretGuardPlugin / authzPlugin /
6+
* goes through the full pathEscapePlugin / secretGuardPlugin /
77
* permissionPlugin stack (see buildCorePosixToolPlugins in
88
* posix-tool-plugins.ts). `requireRelativePath` in codex-apply-patch.ts only
99
* rejects absolute paths — it does nothing about `../` traversal — so this

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createPosixTools, composeMiddleware } from "@intx/tools-posix";
77
import type { ToolPlugin } from "@intx/tools-posix";
88
import type { ToolCall, ToolResult } from "@intx/types/runtime";
99
import { createPermissionGate } from "../permission/gate.js";
10+
import { BLOCKED_BY_POLICY_PREFIX } from "../permission/decline-markers.js";
1011
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
1112
import {
1213
createCompositeBlobReader,
@@ -688,4 +689,52 @@ describe("buildCorePosixToolPlugins", () => {
688689
expect(content).not.toContain(straddlingSecret);
689690
expect(content).not.toMatch(/AKIA[0-9A-Z]*/);
690691
});
692+
693+
test("catastrophic shell stays denied with secret-guard ahead of the gate in skipPermissions mode (CL-7950)", async () => {
694+
// The pass-through hard-deny plugin folded into the gate verdict path:
695+
// with no separate enforcement plugin left in the chain, the gate itself
696+
// must deny catastrophic shell even when skipPermissions auto-allows
697+
// everything else, and secret-guard must still sit ahead of it.
698+
const cwd = await mkdtemp(join(tmpdir(), "cl7950-fold-"));
699+
try {
700+
const gate = createPermissionGate({
701+
approvals: [],
702+
interactive: false,
703+
skipPermissions: true,
704+
reactorGated: false,
705+
cwd,
706+
});
707+
const plugins = buildCorePosixToolPlugins({ cwd, permissionGate: gate });
708+
const secretGuardIndex = findMiddlewareIndex(
709+
plugins,
710+
"Access to sensitive file blocked by policy",
711+
);
712+
const permissionIndex = findMiddlewareIndex(plugins, "gateToolCall");
713+
expect(secretGuardIndex).toBeGreaterThanOrEqual(0);
714+
expect(permissionIndex).toBeGreaterThanOrEqual(0);
715+
expect(secretGuardIndex).toBeLessThan(permissionIndex);
716+
717+
const composed = composeMiddleware(
718+
plugins
719+
.map((plugin) => plugin.middleware)
720+
.filter((mw): mw is NonNullable<typeof mw> => mw !== undefined),
721+
async (call) => ({ callId: call.id, content: "reached terminal" }),
722+
);
723+
const signal = new AbortController().signal;
724+
const blocked = await composed(
725+
{ id: "c1", name: "run_shell", arguments: { command: "sudo reboot" } },
726+
signal,
727+
);
728+
expect(blocked.isError).toBe(true);
729+
expect(String(blocked.content)).toContain(BLOCKED_BY_POLICY_PREFIX);
730+
const allowed = await composed(
731+
{ id: "c2", name: "run_shell", arguments: { command: "echo hi" } },
732+
signal,
733+
);
734+
expect(allowed.isError).not.toBe(true);
735+
expect(String(allowed.content)).toContain("hi");
736+
} finally {
737+
await rm(cwd, { recursive: true, force: true });
738+
}
739+
});
691740
});

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { evidenceArchivePathGuardPlugin } from "../plugins/evidence-archive-path
55
import { evidenceArchiveSearchPlugin } from "../plugins/evidence-archive-search-plugin.js";
66
import { deleteFilePlugin } from "../plugins/delete-file-plugin.js";
77
import { secretGuardPlugin } from "../plugins/secret-guard-plugin.js";
8-
import { authzPlugin } from "../plugins/authz-plugin.js";
98
import { permissionPlugin } from "../plugins/permission-plugin.js";
109
import { verifyPlugin } from "../plugins/verify-plugin.js";
1110
import { editFileDiagnosticsPlugin } from "../plugins/edit-file-diagnostics-plugin.js";
@@ -96,8 +95,8 @@ export function buildCorePosixToolPlugins(
9695
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
9796
// cwd are not hard-denied after the gate already auto-allows. Pass a live
9897
// getter so `/yolo` mid-session unlocks (or re-enforces) bounds without
99-
// rebuilding the plugin stack. Secret-guard and authz still hard-deny
100-
// regardless.
98+
// rebuilding the plugin stack. Secret-guard and the gate's
99+
// catastrophic-shell check still hard-deny regardless.
101100
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
102101
// One shared workspace-roots provider for every bound in this stack, so
103102
// pathEscape and delete_file admit the same registered sibling worktrees.
@@ -120,7 +119,6 @@ export function buildCorePosixToolPlugins(
120119
deleteFilePlugin(cwd, { allowOutside, rootsProvider }),
121120
toolOutputUriPlugin(),
122121
secretGuardPlugin(),
123-
authzPlugin(),
124122
permissionPlugin(permissionGate),
125123
shellGuardPlugin(cwd, shellTimeout, shellEnv, {
126124
allowOutsideCwd: allowOutside,

‎src/permission/authz-grants.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,14 @@ export interface EvaluateApprovalsInput {
9797
workspace: GrantWorkspace;
9898
}
9999

100-
// Grant-store evaluation via @intx/authz. Filters provider-model and cwd via
101-
// grantScopeMatches, then asks evaluateGrants for the highest-specificity
102-
// allow among package-compatible grants. Exact-escaped grants are checked with
103-
// matchesPattern (equality after unescape) first so a stored exact command is
104-
// never lost.
105-
export async function evaluateApprovals(
100+
// Grant-evaluation owner for the live decide() path: the shell per-segment
101+
// checks and the path-arg check inside decide() resolve coverage through this
102+
// function. The queued-request reconciliation path (isRequestCoveredByApprovals
103+
// in gate.ts) matches inline against the same scope helper and pattern
104+
// matcher instead of calling here, so keep the two in sync when changing
105+
// matching semantics. Fail-closed throughout:
106+
// unknown tools, unknown runners, and empty grant lists all refuse.
107+
export async function approvalCoversSubject(
106108
input: EvaluateApprovalsInput,
107109
): Promise<boolean> {
108110
const {
@@ -135,3 +137,14 @@ export async function evaluateApprovals(
135137
const decision = await evaluateGrants(grants, subject, tool);
136138
return decision.effect === "allow";
137139
}
140+
141+
// Grant-store evaluation via @intx/authz. Filters provider-model and cwd via
142+
// grantScopeMatches, then asks evaluateGrants for the highest-specificity
143+
// allow among package-compatible grants. Exact-escaped grants are checked with
144+
// matchesPattern (equality after unescape) first so a stored exact command is
145+
// never lost.
146+
export async function evaluateApprovals(
147+
input: EvaluateApprovalsInput,
148+
): Promise<boolean> {
149+
return approvalCoversSubject(input);
150+
}

‎src/permission/gate.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ const shellCall = (command: string): ToolCall => ({
3232
//
3333
// The shell-authz hard-deny cases are not independently reachable through
3434
// reconciliation today — evaluate() already denies and returns before such a
35-
// request is ever queued (see the block-reason check ahead of the per-request
36-
// loop), so a queued entry has always already cleared this guard. They stay
35+
// request is ever queued (see the block-reason check at the top of the
36+
// verdict path), so a queued entry has always already cleared this guard. They stay
3737
// in preGrantGuardReason and this table anyway as drift-resistance: if a
3838
// future refactor ever let a hard-denied command reach the queue, this still
3939
// catches it.

‎src/permission/gate.ts‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
2929
import { matchesPattern, escapeGlobLiteral } from "./matcher.js";
3030
import {
31-
evaluateApprovals,
31+
approvalCoversSubject,
3232
grantScopeMatches,
3333
type GrantWorkspace,
3434
} from "./authz-grants.js";
@@ -645,6 +645,24 @@ export function createPermissionGate(
645645
};
646646

647647
const decide = async (call: ToolCall): Promise<GateDecision> => {
648+
// Catastrophic shell commands are hard-denied here, at the top of the
649+
// single verdict path every entry (evaluate, authorizeCall,
650+
// executionVerdict) flows through — this is the owning enforcement point
651+
// for the run-shell-authz classification. The verdict is invariant across
652+
// modes: auto, headless, and skipPermissions never allow these commands.
653+
// Judged against the full command string, not per split segment, so a
654+
// stage that only reads bounded, already-piped data (e.g.
655+
// `git show sha:path | rg -n foo`) is not denied in isolation when the
656+
// full pipeline is exempt. This runs before every grant shortcut — a
657+
// stored grant must never admit a hard-denied command (see
658+
// preGrantGuardReason).
659+
if (call.name === "run_shell") {
660+
const command = String(call.arguments.command ?? "");
661+
const blockReason = runShellAuthzBlockReason(command);
662+
if (blockReason !== undefined) {
663+
return { kind: "deny", reason: blockReason };
664+
}
665+
}
648666
if (skipPermissions) return { kind: "allow" };
649667
// Sub-agent tool calls run under ALS identity (identity-context.ts). The
650668
// process cwd is the worktree (or session when no identity is set); every
@@ -751,20 +769,8 @@ export function createPermissionGate(
751769
);
752770
if (segments.length === 0) continue;
753771

754-
// A command authz would hard-deny at execution is stricter than "ask":
755-
// the gate must deny the call outright rather than show an Accept
756-
// button for a command that can never actually run. Judged against the
757-
// full command string with the same predicate authz enforces at
758-
// execution time — not per split segment — so a stage that only reads
759-
// bounded, already-piped data (e.g. `git show sha:path | rg -n foo`)
760-
// is not denied in isolation when the full pipeline is exempt. This
761-
// must run before the exact-full-command grant shortcut below — a
762-
// stored grant must never let a hard-denied command skip straight
763-
// past the check that would otherwise deny it (see preGrantGuardReason).
764-
const blockReason = runShellAuthzBlockReason(fullCommand);
765-
if (blockReason !== undefined) {
766-
return { kind: "deny", reason: blockReason };
767-
}
772+
// Catastrophic commands were already hard-denied at the top of the
773+
// verdict path before any grant shortcut could admit them.
768774

769775
let needsOperator = false;
770776
let anySecret = false;
@@ -790,7 +796,7 @@ export function createPermissionGate(
790796
// so. Matching semantics are untouched; this only annotates the ask.
791797
if (
792798
mismatchNotice === undefined &&
793-
(await evaluateApprovals({
799+
(await approvalCoversSubject({
794800
tool: request.tool,
795801
subject: segment,
796802
approvals,
@@ -804,7 +810,7 @@ export function createPermissionGate(
804810
continue;
805811
}
806812
if (
807-
await evaluateApprovals({
813+
await approvalCoversSubject({
808814
tool: request.tool,
809815
subject: segment,
810816
approvals,
@@ -865,7 +871,7 @@ export function createPermissionGate(
865871

866872
// Path-arg tools already drop to ask via callTargetsRestricted; grants
867873
// match on the path subject the same as before.
868-
const alreadyApproved = await evaluateApprovals({
874+
const alreadyApproved = await approvalCoversSubject({
869875
tool: request.tool,
870876
subject: request.subject,
871877
approvals,

0 commit comments

Comments
 (0)