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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
- A session falls back to the next resolvable provider when the selected
default or project-local provider is missing or incomplete. `--provider`
still errors.
- Operator approval resume late-binds to the live agent and keeps the TUI
busy across the overlay so a reload cannot drop the parked tool call.
- Operator approval drops on session identity change. inFlight occupancy
owns idle rebuild; delivery generation owns session identity, so interrupt,
/clear, and /new abort the outstanding overlay, skip minting a grant, and
notify the operator instead of delivering into a rebuilt agent.

### Changed

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
- **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` 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()`.
- **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.

- **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).
Expand Down
42 changes: 27 additions & 15 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ function classifyOutcome(outcome: ApprovalOutcome | undefined): ApprovalOutcomeK
if (!outcome.allow) {
const message = outcome.message ?? "";
if (message.includes("timed out")) return "timeout";
if (message.includes("no longer running")) return "abort";
if (message.includes("no longer running") || message.includes("identity changed")) {
return "abort";
}
return "deny";
}
return outcome.persist !== undefined ? "allow-with-scope" : "allow-once";
Expand Down Expand Up @@ -325,8 +327,12 @@ export interface PermissionGate {
// 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>;
// outcome's grant when the session identity is still current). Returns
// undefined when no outcome arrived.
resolveSuspended: (
request: PermissionRequest,
stillCurrent?: () => boolean,
) => Promise<ApprovalOutcome | undefined>;
// 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
Expand Down Expand Up @@ -724,7 +730,10 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// span, await the requestApproval seam, settle the log/span, and mint any
// grant the outcome carries (never for secret-path shell). Returns undefined
// when no outcome arrived (timeout/abort auto-deny paths).
const resolveInteractiveAsk = async (decision: Extract<GateDecision, { kind: "ask" }>) => {
const resolveInteractiveAsk = async (
decision: Extract<GateDecision, { kind: "ask" }>,
stillCurrent?: () => boolean,
) => {
const { request, anySecret, segmentCount } = decision;
const askRule = anySecret ? "sensitive-path" : undefined;
const ask = approvalLog.ask({
Expand Down Expand Up @@ -752,7 +761,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
finishApprovalWait(telemetry, waitSpanId, request.tool, outcome);
ask.settle(classifyOutcome(outcome));
}
if (outcome !== undefined && outcome.allow && !anySecret) {
if (outcome !== undefined && outcome.allow && !anySecret && (stillCurrent?.() ?? true)) {
mintGrant(request.tool, outcome);
}
return outcome;
Expand Down Expand Up @@ -823,18 +832,21 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// 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.
const resolveSuspended = (request: PermissionRequest) => {
const resolveSuspended = (request: PermissionRequest, stillCurrent?: () => boolean) => {
const anySecret =
request.tool === "run_shell" && commandReferencesSensitivePath(request.subject) !== undefined;
return resolveInteractiveAsk({
kind: "ask",
request,
anySecret,
segmentCount:
request.tool === "run_shell"
? splitChainedCommand(request.subject).filter((s) => !isShellCommentOnly(s)).length
: 0,
});
return resolveInteractiveAsk(
{
kind: "ask",
request,
anySecret,
segmentCount:
request.tool === "run_shell"
? splitChainedCommand(request.subject).filter((s) => !isShellCommentOnly(s)).length
: 0,
},
stillCurrent,
);
};

const reset = (): void => {
Expand Down
129 changes: 97 additions & 32 deletions src/session/approval-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type { PermissionRequest } from "../permission/types.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "approval-resume"]);

export const APPROVAL_DROPPED_NOTICE = "Approval dropped because the session changed.";

const ApprovalSnapshotShape = type({
name: "string",
"arguments?": "Record<string, unknown>",
Expand Down Expand Up @@ -108,52 +110,115 @@ function decisionMessage(
}

export function createApprovalResume(args: {
// Late-bound: the live agent is read at handle() time so rebuilds
// (/clear, model switch) deliver through the current instance.
// Live agent at history/deliver time. TUI occupancy holds this identity
// until the correlated resume is accepted; a generation bump aborts the
// gate rather than retargeting a rebuilt agent.
getAgent: () => Pick<Agent, "deliver" | "history"> | undefined;
// TUI session queue. When present, each decision is awaited through this
// seam; exec omits it and uses getAgent().deliver.
deliver?: (message: InboundMessage, stillCurrent: () => boolean) => void | Promise<void>;
// TUI: capture at handle() start so interrupt, /clear, or /new during the
// overlay aborts the gate and drops the decision. Exec omits this.
captureGeneration?: () => () => boolean;
// TUI: operator-visible notice when an overlay decision is dropped after
// a generation bump.
onDropped?: (text: string) => void;
// TUI: interrupt/clear bump this to reject the parked call on the old
// agent before close/rebuild. Cleared when handle returns.
registerParkedCancel?: (cancel: (() => void) | undefined) => void;
gate: PermissionGate;
}): ApprovalResume {
const { getAgent, gate } = args;

const requireAgent = (): Pick<Agent, "deliver" | "history"> => {
const agent = getAgent();
if (agent === undefined) {
throw new Error("approval resume: no live agent");
}
return agent;
};

return {
handle: async (result) => {
if (result.type !== "suspended") return false;
const agent = getAgent();
if (agent === undefined) return true;
const stillCurrent = args.captureGeneration?.() ?? (() => true);
const parkedAgent = requireAgent();
const { correlationId, approvalSnapshot } = result;

// Turn-count watermark for the settled guard below: a "approval timed
// out" tool result appended after this point means the reactor settled
// this very correlation before our decision lands.
const turnsAtSuspend = (await agent.history()).length;
let cancelled = false;
const cancelParked = (): void => {
if (cancelled) return;
cancelled = true;
parkedAgent.deliver(decisionMessage(correlationId, "rejected", APPROVAL_DROPPED_NOTICE));
};
args.registerParkedCancel?.(cancelParked);

if (approvalSnapshot === undefined) {
// A suspension without a snapshot cannot be surfaced; fail closed by
// rejecting the parked call so the run does not hang on an invisible
// gate.
agent.deliver(decisionMessage(correlationId, "rejected", "approval surface unavailable"));
return true;
}
const dropParked = (): void => {
args.onDropped?.(APPROVAL_DROPPED_NOTICE);
cancelParked();
};

const request = requestFromApprovalSnapshot(approvalSnapshot, correlationId);
if (request === null) {
agent.deliver(decisionMessage(correlationId, "rejected", "approval surface unavailable"));
return true;
}
try {
const deliverDecision = async (message: InboundMessage): Promise<void> => {
if (!stillCurrent()) return;
if (args.deliver !== undefined) {
await args.deliver(message, stillCurrent);
return;
}
requireAgent().deliver(message);
};

const outcome = await gate.resolveSuspended(request);
if (settledAfterSuspend(await agent.history(), turnsAtSuspend)) {
// The reactor already answered the parked call (its approval timeout
// fired while the surface was still up). Delivering now would append
// the raw decision JSON as an uncorrelated user turn — drop and log.
logger.warn`late approval decision dropped correlation=${correlationId} outcome=${outcome?.allow === true ? "approved" : "rejected"}`;
return true;
}
if (outcome === undefined || !outcome.allow) {
agent.deliver(decisionMessage(correlationId, "rejected", outcome?.message));
// Turn-count watermark for the settled guard below: a "approval timed
// out" tool result appended after this point means the reactor settled
// this very correlation before our decision lands.
const turnsAtSuspend = (await parkedAgent.history()).length;
if (!stillCurrent()) {
dropParked();
return true;
}

if (approvalSnapshot === undefined) {
// A suspension without a snapshot cannot be surfaced; fail closed by
// rejecting the parked call so the run does not hang on an invisible
// gate.
args.registerParkedCancel?.(undefined);
await deliverDecision(
decisionMessage(correlationId, "rejected", "approval surface unavailable"),
);
return true;
}

const request = requestFromApprovalSnapshot(approvalSnapshot, correlationId);
if (request === null) {
args.registerParkedCancel?.(undefined);
await deliverDecision(
decisionMessage(correlationId, "rejected", "approval surface unavailable"),
);
return true;
}

const outcome = await gate.resolveSuspended(request, stillCurrent);
if (!stillCurrent()) {
dropParked();
return true;
}
args.registerParkedCancel?.(undefined);
if (settledAfterSuspend(await requireAgent().history(), turnsAtSuspend)) {
// The reactor already answered the parked call (its approval timeout
// fired while the surface was still up). Delivering now would append
// the raw decision JSON as an uncorrelated user turn — drop and log.
logger.warn`late approval decision dropped correlation=${correlationId} outcome=${outcome?.allow === true ? "approved" : "rejected"}`;
return true;
}
if (outcome === undefined || !outcome.allow) {
await deliverDecision(decisionMessage(correlationId, "rejected", outcome?.message));
return true;
}
await deliverDecision(decisionMessage(correlationId, "approved"));
return true;
} finally {
args.registerParkedCancel?.(undefined);
}
agent.deliver(decisionMessage(correlationId, "approved"));
return true;
},
};
}
Loading
Loading