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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.

## [Unreleased]

### Security

- Reactor-gated tool middleware still blocks policy denials. A `decide()` deny
(authorization hard-deny, auto-shell deny, or headless deny) returns a
blocked tool error and does not run the call. Ask and allow still skip the
middleware prompt so an approved re-dispatch never re-asks. Middleware is
not a second copy of `env.authorize`: it consumes the prior verdict when the
same call (id, name, and arguments) is cached, and decides on a cache miss.

## [0.3.18] - 2026-09-08

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ tool call
- **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.
- **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()`.
- **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()`.

- **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).
- **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.
Expand Down
90 changes: 64 additions & 26 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,35 +295,41 @@ export interface PermissionGateOptions {
// silent by construction.
telemetry?: Telemetry | undefined;
// This gate's decisions are consumed by the reactor's before-tool authz
// seam (env.authorize) instead of the tool-runner middleware. Set for the
// main session so approved re-dispatches skip the middleware gate; kept
// false for sub-agents, which still gate in the middleware. Required so a
// caller cannot silently fall back to middleware gating by omitting it.
// seam (env.authorize) instead of evaluate() in the tool-runner middleware.
// Set for the main session so approved re-dispatches skip the middleware
// prompt; kept false for sub-agents, which still gate via evaluate().
// Required so a caller cannot silently fall back to middleware gating by
// omitting it.
reactorGated: boolean;
// Ask/settle event log (see approval-log.ts): one record per consequential
// decision, auto or interactive. Defaults to a no-op so nothing depends on
// logging being wired.
approvalLog?: ApprovalLog;
}

export type AuthorizeVerdict =
| { effect: "allow" }
| { effect: "deny"; reason: string }
| { effect: "ask"; request: PermissionRequest };

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

// Consume-once handoff from env.authorize to execution-time middleware.
// Not session-lifetime uniqueness: call.id is reused for every Codex proxy
// inner posix op, so a lasting set would mute later JSONL records. A hit
// still requires matching name and arguments so a reused id cannot apply an
// outer allow to a different inner tool. Nested posix with the same id still
// consume-once when identity matches. reset() clears leftovers (outer tools
// that never hit posix middleware).
const authorizedByCallId = new Map<
string,
{ name: string; arguments: ToolCall["arguments"]; verdict: AuthorizeVerdict }
>();

// Non-blocking policy decision for one tool call: everything the gate owns —
// tier pre-filter, auto rules, pre-grant guards, grants, headless denial —
// resolved WITHOUT waiting on an operator. `ask` carries the fully-built
Expand Down Expand Up @@ -718,9 +736,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
};

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

const authorizeCall = async (call: ToolCall): Promise<AuthorizeVerdict> => {
const verdict = mapAuthorizeVerdict(await decide(call));
authorizedByCallId.set(call.id, {
name: call.name,
arguments: call.arguments,
verdict,
});
return verdict;
};

const executionVerdict = async (call: ToolCall): Promise<AuthorizeVerdict> => {
const cached = authorizedByCallId.get(call.id);
if (
cached !== undefined &&
cached.name === call.name &&
JSON.stringify(cached.arguments) === JSON.stringify(call.arguments)
) {
authorizedByCallId.delete(call.id);
return cached.verdict;
}
return mapAuthorizeVerdict(await decide(call));
};

// Resolve a suspended reactor approval once the operator answers. The
// request is the one authorizeCall built at decision time, so the ask log,
// wait span, and grant minting are identical to the middleware path.
Expand All @@ -782,6 +818,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
if (index !== -1) approvals.splice(index, 1);
}
sessionGrants.length = 0;
authorizedByCallId.clear();
};

const sameApproval = (a: Approval, b: Approval): boolean =>
Expand Down Expand Up @@ -814,6 +851,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
return {
evaluate,
authorizeCall,
executionVerdict,
resolveSuspended,
isReactorGated: () => reactorGated,
getApprovals: () => approvals,
Expand Down
Loading
Loading