Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ 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.

## [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` still blocks a `decide()` deny (authz hard-deny, auto-shell deny, headless deny) and does not call `next`; an `ask` or `allow` skips the middleware prompt 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()`.

- **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
40 changes: 25 additions & 15 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,11 @@ 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
Expand All @@ -321,9 +322,9 @@ export interface PermissionGate {
// 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.
// (env.authorize) rather than by evaluate() in the tool-runner middleware.
// Under reactor gating, gateToolCall still blocks decide() deny; 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 @@ -470,11 +471,20 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// settled all collapse to now. Interactive prompts use approvalLog.ask
// directly (see below) so their real queued/displayed/settled timestamps
// are captured.
//
// Recording owns uniqueness: reactor-gated calls run decide() twice (env.authorize
// then gateToolCall) and decide() is not otherwise idempotent. A second pass
// for the same call.id must still return deny, but must not append a second
// JSONL record.
const recordedCallIds = new Set<string>();
const recordAutoDecision = (
callId: string,
tool: string,
rule: string | undefined,
outcome: ApprovalOutcomeKind,
): void => {
if (recordedCallIds.has(callId)) return;
recordedCallIds.add(callId);
approvalLog
.ask({
tool,
Expand Down Expand Up @@ -545,15 +555,15 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// a secret path is ask so an explicit one-time approval can pass it.
const shellRule = autoShellRuleForCall(call, isRestrictedHere, effectiveCwd, rootsProvider);
if (shellRule?.effect === "deny") {
recordAutoDecision(call.name, shellRule.name, "auto-deny");
recordAutoDecision(call.id, call.name, shellRule.name, "auto-deny");
return { kind: "deny", reason: shellRule.reason };
}
if (shellRule === undefined) {
recordAutoDecision(call.name, undefined, "auto-allow");
recordAutoDecision(call.id, call.name, undefined, "auto-allow");
return { kind: "allow" };
}
} else if (!restricted && AUTO_ALLOWED_TOOLS.has(call.name)) {
recordAutoDecision(call.name, "auto-allowed-tool", "auto-allow");
recordAutoDecision(call.id, call.name, "auto-allowed-tool", "auto-allow");
return { kind: "allow" };
}
// Any other tool in auto mode (MCP or unknown built-in) is not
Expand Down Expand Up @@ -632,7 +642,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
const askRule = anySecret ? "sensitive-path" : undefined;

if (!interactive || requestApproval === undefined) {
recordAutoDecision(request.tool, askRule ?? "non-interactive", "deny");
recordAutoDecision(call.id, request.tool, askRule ?? "non-interactive", "deny");
return {
kind: "deny",
reason: anySecret
Expand Down Expand Up @@ -667,7 +677,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
}

if (!interactive || requestApproval === undefined) {
recordAutoDecision(request.tool, "non-interactive", "deny");
recordAutoDecision(call.id, request.tool, "non-interactive", "deny");
return {
kind: "deny",
reason: `${request.action} requires operator approval, which is unavailable in a non-interactive run. Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`,
Expand Down Expand Up @@ -718,9 +728,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 authorizeCall 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 Down
Loading
Loading