Skip to content

Commit a9fe428

Browse files
Merge pull request #844 from corbitsdev/cl-7530-gatetoolcall-middleware-bypass-under-reactorgated-is
Block policy denials in reactor-gated tool middleware
2 parents 474e44e + 8246612 commit a9fe428

6 files changed

Lines changed: 599 additions & 37 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Security
17+
18+
- Reactor-gated tool middleware still blocks policy denials. A `decide()` deny
19+
(authorization hard-deny, auto-shell deny, or headless deny) returns a
20+
blocked tool error and does not run the call. Ask and allow still skip the
21+
middleware prompt so an approved re-dispatch never re-asks. Middleware is
22+
not a second copy of `env.authorize`: it consumes the prior verdict when the
23+
same call (id, name, and arguments) is cached, and decides on a cache miss.
24+
1425
## [0.3.18] - 2026-09-08
1526

1627
### Added

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ tool call
377377
- **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).
378378
- **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`.
379379
- **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.
380-
- **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. Under reactor gating the middleware/MCP `gateToolCall` bypasses the gate so an approved re-dispatch never re-asks. The headless denial and the stricter chained-command hard-deny are preserved as deny effects (upstream `block`s) decided inside the same `decide()`.
380+
- **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. 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()`.
381381

382382
- **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax).
383383
- **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality.

‎src/permission/gate.ts‎

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -295,35 +295,41 @@ export interface PermissionGateOptions {
295295
// silent by construction.
296296
telemetry?: Telemetry | undefined;
297297
// This gate's decisions are consumed by the reactor's before-tool authz
298-
// seam (env.authorize) instead of the tool-runner middleware. Set for the
299-
// main session so approved re-dispatches skip the middleware gate; kept
300-
// false for sub-agents, which still gate in the middleware. Required so a
301-
// caller cannot silently fall back to middleware gating by omitting it.
298+
// seam (env.authorize) instead of evaluate() in the tool-runner middleware.
299+
// Set for the main session so approved re-dispatches skip the middleware
300+
// prompt; kept false for sub-agents, which still gate via evaluate().
301+
// Required so a caller cannot silently fall back to middleware gating by
302+
// omitting it.
302303
reactorGated: boolean;
303304
// Ask/settle event log (see approval-log.ts): one record per consequential
304305
// decision, auto or interactive. Defaults to a no-op so nothing depends on
305306
// logging being wired.
306307
approvalLog?: ApprovalLog;
307308
}
308309

310+
export type AuthorizeVerdict =
311+
| { effect: "allow" }
312+
| { effect: "deny"; reason: string }
313+
| { effect: "ask"; request: PermissionRequest };
314+
309315
export interface PermissionGate {
310316
evaluate: (call: ToolCall) => Promise<GateVerdict>;
311317
// Reactor-path policy: the same decision evaluate() makes, as the effect the
312318
// vendored before-tool authz hook consumes (see authorizeCall above).
313-
authorizeCall: (
314-
call: ToolCall,
315-
) => Promise<
316-
| { effect: "allow" }
317-
| { effect: "deny"; reason: string }
318-
| { effect: "ask"; request: PermissionRequest }
319-
>;
319+
authorizeCall: (call: ToolCall) => Promise<AuthorizeVerdict>;
320+
// Execution-time backstop for reactor-gated posix/MCP middleware: consume the
321+
// authorizeCall verdict when the same call identity (id, name, arguments) is
322+
// cached; decide only on a miss (nested posix whose outer tool is not
323+
// run_shell, colliding reused ids, and tests).
324+
executionVerdict: (call: ToolCall) => Promise<AuthorizeVerdict>;
320325
// Resolve a suspended reactor approval against the operator (and mint the
321326
// outcome's grant). Returns undefined when no outcome arrived.
322327
resolveSuspended: (request: PermissionRequest) => Promise<ApprovalOutcome | undefined>;
323-
// True when this gate's decisions are consumed by the reactor's authz seam
324-
// (env.authorize) rather than by the tool-runner middleware. Tool-runner
325-
// gating (gateToolCall) is bypassed under reactor gating so an approved
326-
// re-dispatch runs without a second prompt.
328+
// True when this gate's decisions go through env.authorize (authorizeCall)
329+
// rather than evaluate() in the tool-runner middleware. Under reactor gating,
330+
// gateToolCall is an execution backstop: it consumes a matching cached
331+
// verdict and decides on a miss. Deny still blocks; ask/allow skip the
332+
// middleware prompt so an approved re-dispatch never re-asks.
327333
isReactorGated: () => boolean;
328334
// The gate's current in-memory approvals, including any granted this session.
329335
getApprovals: () => readonly Approval[];
@@ -484,6 +490,18 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
484490
.settle(outcome);
485491
};
486492

493+
// Consume-once handoff from env.authorize to execution-time middleware.
494+
// Not session-lifetime uniqueness: call.id is reused for every Codex proxy
495+
// inner posix op, so a lasting set would mute later JSONL records. A hit
496+
// still requires matching name and arguments so a reused id cannot apply an
497+
// outer allow to a different inner tool. Nested posix with the same id still
498+
// consume-once when identity matches. reset() clears leftovers (outer tools
499+
// that never hit posix middleware).
500+
const authorizedByCallId = new Map<
501+
string,
502+
{ name: string; arguments: ToolCall["arguments"]; verdict: AuthorizeVerdict }
503+
>();
504+
487505
// Non-blocking policy decision for one tool call: everything the gate owns —
488506
// tier pre-filter, auto rules, pre-grant guards, grants, headless denial —
489507
// resolved WITHOUT waiting on an operator. `ask` carries the fully-built
@@ -718,9 +736,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
718736
};
719737

720738
// Middleware path: blocking evaluation used by tool-runner consumers whose
721-
// calls never pass through the reactor (sub-agents, late MCP wrappers).
722-
// When the gate is reactor-gated this is bypassed entirely — the reactor's
723-
// before-tool authz hook owns the decision (see authorizeCall / gateToolCall).
739+
// calls never pass through the reactor (sub-agents). When the gate is
740+
// reactor-gated, gateToolCall uses executionVerdict instead of evaluate() so
741+
// deny still blocks and ask never re-prompts (see gateToolCall).
724742
const evaluate = async (call: ToolCall): Promise<GateVerdict> => {
725743
const decision = await decide(call);
726744
if (decision.kind === "allow") return { allowed: true };
@@ -741,14 +759,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
741759
// vendored before-tool authz hook consumes. `allow` proceeds, `deny` becomes
742760
// an upstream `block`, and `ask` suspends the call as a PendingOperation
743761
// keyed by the hook-minted correlationId — no resolve closure is held here.
744-
const authorizeCall = async (
745-
call: ToolCall,
746-
): Promise<
747-
| { effect: "allow" }
748-
| { effect: "deny"; reason: string }
749-
| { effect: "ask"; request: PermissionRequest }
750-
> => {
751-
const decision = await decide(call);
762+
// Stashes the verdict for gateToolCall to consume so middleware is not a
763+
// second copy of env.authorize.
764+
const mapAuthorizeVerdict = (decision: GateDecision): AuthorizeVerdict => {
752765
switch (decision.kind) {
753766
case "allow":
754767
return { effect: "allow" };
@@ -759,6 +772,29 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
759772
}
760773
};
761774

775+
const authorizeCall = async (call: ToolCall): Promise<AuthorizeVerdict> => {
776+
const verdict = mapAuthorizeVerdict(await decide(call));
777+
authorizedByCallId.set(call.id, {
778+
name: call.name,
779+
arguments: call.arguments,
780+
verdict,
781+
});
782+
return verdict;
783+
};
784+
785+
const executionVerdict = async (call: ToolCall): Promise<AuthorizeVerdict> => {
786+
const cached = authorizedByCallId.get(call.id);
787+
if (
788+
cached !== undefined &&
789+
cached.name === call.name &&
790+
JSON.stringify(cached.arguments) === JSON.stringify(call.arguments)
791+
) {
792+
authorizedByCallId.delete(call.id);
793+
return cached.verdict;
794+
}
795+
return mapAuthorizeVerdict(await decide(call));
796+
};
797+
762798
// Resolve a suspended reactor approval once the operator answers. The
763799
// request is the one authorizeCall built at decision time, so the ask log,
764800
// wait span, and grant minting are identical to the middleware path.
@@ -782,6 +818,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
782818
if (index !== -1) approvals.splice(index, 1);
783819
}
784820
sessionGrants.length = 0;
821+
authorizedByCallId.clear();
785822
};
786823

787824
const sameApproval = (a: Approval, b: Approval): boolean =>
@@ -814,6 +851,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
814851
return {
815852
evaluate,
816853
authorizeCall,
854+
executionVerdict,
817855
resolveSuspended,
818856
isReactorGated: () => reactorGated,
819857
getApprovals: () => approvals,

0 commit comments

Comments
 (0)