Skip to content
4 changes: 4 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ OAuth stores serialize refresh writes and treat the value observed under the sto

The interactive TUI may offer an explicit alternate-provider/model selector only after the same-profile retry also ends in a terminal credential failure. That choice is generation-scoped and consumed once. It may continue the original operator message only when the failed attempt emitted no committing inference event; after any commitment it switches the live provider without replaying the message. `/connect` replaces or adds credentials and `/model` switches the live source explicitly. Exec and fleet workers use the same one-shot same-profile recovery but never open an auth or alternate-provider prompt; an exhausted failure terminates that attempt with a sanitized recovery diagnostic.

### Worker permission grants

A worker deny-on-ask registers a harness-owned denied-call envelope (`src/permission/worker-grant.ts`): the exact denied ToolCall plus a stable path-aware fingerprint, keyed by worker session with a ten-minute wall-clock TTL. The `turnId` on the envelope is metadata only — no turn enforcement exists, so expiry is purely `expiresAt`-driven. The deny reason names only the envelope's requestId; the worker's `ask_director` prose carries no authority and plain `send_input` text can never mint, consume, or extend an envelope. The worker quotes that requestId back as `grant_request_id`, binding the ask to its own denial at register time; an unnamed ask keeps the legacy first-pending bind. The parent observes the envelope on the ask record via the session-store subscription, replays the exact call through its own gate operator path, and retries the retained worker via the existing `resume_agent` with exact args plus the questionId ref. The first covering-grant retry consumes the envelope; replays, tampered args/cwd/tool/session, decline, or interrupt fail closed with a truthful blocker. Expiry deliberately does not fail closed: the lapsed envelope is marked, skipped, and the retry falls through to a fresh gate round that denies fresh with a new grant id, so a lapsed window can never strand the worker. Expiry is lazy — every pendency read sweeps overdue envelopes, and no periodic scheduler exists by decision, not omission. Concurrent identical retries serialize on a per-fingerprint mutex across the precheck-to-consume gap, so one envelope allows exactly once. There is no dedicated grant verb — single-use and exactness are enforced by the envelope sidecar, not by tool plumbing. Phase 2 (by design, not here): a broad parent session grant can cover more than the exact denied subject; binding the grant to the envelope args needs the atomic grant-and-retry verb.

### TUI Runner (`src/tui/runner/`)

- Builds a chat-mode agent using the `ChatDirector`
Expand Down
213 changes: 205 additions & 8 deletions src/permission/reactor-authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ import type { AuthzCallResult } from "@intx/inference";
import { WORKER_CANNOT_COMPLETE_APPROVAL } from "./decline-markers.js";
import type { AuthorizeVerdict, GateVerdict, PermissionGate } from "./gate.js";
import type { PermissionRequest } from "./types.js";
import {
createDeniedCallEnvelope,
fingerprintDeniedCall,
formatWorkerDenyWithGrantId,
getProcessWorkerGrantStore,
type WorkerGrantStore,
} from "./worker-grant.js";
import { canonicalToolName } from "../agent/canonical-tool-name.js";
import { getSubAgentIdentity } from "../subagent/identity-context.js";
import {
FLEET_VERBS,
ORCHESTRATOR_ONLY_FLEET_VERBS,
Expand Down Expand Up @@ -71,43 +79,231 @@ function readAuthorizeToolCall(
return call satisfies ToolCallType;
}

export interface WorkerGrantOptions {
/** Defaults to the process-shared sidecar (worker deny side registers,
* parent observes/consumes). Tests pass an isolated store. */
store?: WorkerGrantStore;
/** Owning worker session; absent means no envelope is minted or matched. */
sessionId?: string | (() => string | undefined);
workspaceRoot?: string;
}

function resolveWorkerSessionId(
sessionId: WorkerGrantOptions["sessionId"],
): string | undefined {
return typeof sessionId === "function" ? sessionId() : sessionId;
}

function resolveWorkerCwd(cwd: string | undefined): string {
return cwd ?? getSubAgentIdentity()?.cwd ?? process.cwd();
}

function workerCallIdentity(
sessionId: string,
call: ToolCallType,
cwd: string,
): {
sessionId: string;
canonicalTool: string;
args: Record<string, unknown>;
cwd: string;
} {
return {
sessionId,
canonicalTool: canonicalToolName(call.name),
args: (call.arguments ?? {}) as Record<string, unknown>,
cwd,
};
}

/**
* Harness-owned denied-call sidecar (CL-9475 Phase 1): on a worker
* deny-on-ask, register the exact denied call and name only its requestId in
* the deny reason. Reuses a still-pending envelope for the same exact call
* instead of minting duplicates across reactor retries with fresh call ids.
*/
function denyWorkerCallWithEnvelope(
options: WorkerGrantOptions | undefined,
call: ToolCallType,
request: PermissionRequest,
cwd: string,
baseReason: string,
): { effect: "deny"; reason: string } {
const sessionId =
options !== undefined
? resolveWorkerSessionId(options.sessionId)
: undefined;
if (sessionId === undefined) return { effect: "deny", reason: baseReason };
const store = options?.store ?? getProcessWorkerGrantStore();
const identity = workerCallIdentity(sessionId, call, cwd);
const existing = store.pendingMatch(
identity.sessionId,
identity.canonicalTool,
identity.args,
identity.cwd,
);
const envelope =
existing ??
store.register(
createDeniedCallEnvelope({
callId: call.id,
tool: call.name,
action: request.action,
subject: request.subject,
args: identity.args,
cwd: identity.cwd,
workerSessionId: identity.sessionId,
...(options?.workspaceRoot !== undefined
? { workspaceRoot: options.workspaceRoot }
: {}),
}),
);
return {
effect: "deny",
reason: formatWorkerDenyWithGrantId(baseReason, envelope.requestId),
};
}

/** Mutex key covering the envelope match: concurrent identical worker calls
* serialize through precheck-to-consume so one envelope allows exactly once. */
function workerGrantKey(
sessionId: string,
call: ToolCallType,
cwd: string,
): string {
const identity = workerCallIdentity(sessionId, call, cwd);
return `${sessionId}\n${fingerprintDeniedCall(identity.canonicalTool, identity.args, identity.cwd)}`;
}

async function authorizeWorkerCall(
gate: PermissionGate,
call: ToolCallType,
grantOptions?: WorkerGrantOptions,
cwd?: string,
): Promise<{ effect: "allow" } | { effect: "deny"; reason: string }> {
if (isWorkerControlPlaneTool(call.name)) return { effect: "allow" };
const workerCwd = resolveWorkerCwd(cwd);
const sessionId = resolveWorkerSessionId(grantOptions?.sessionId);
if (sessionId === undefined)
return authorizeWorkerCallInner(gate, call, grantOptions, workerCwd);
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
return store.runExclusive(workerGrantKey(sessionId, call, workerCwd), () =>
authorizeWorkerCallInner(gate, call, grantOptions, workerCwd, {
sessionId,
store,
}),
);
}

async function authorizeWorkerCallInner(
gate: PermissionGate,
call: ToolCallType,
grantOptions: WorkerGrantOptions | undefined,
workerCwd: string,
grant?: { sessionId: string; store: WorkerGrantStore },
): Promise<{ effect: "allow" } | { effect: "deny"; reason: string }> {
if (grant !== undefined) {
const precheck = grant.store.precheck(
workerCallIdentity(grant.sessionId, call, workerCwd),
);
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
}
const verdict = await gate.authorizeCall(call);
// Authorize never consumes: it only permits the call to proceed to the
// tool-runner middleware, which enforces executionVerdict. Consuming here
// would spend the envelope before execution, so the execution backstop's
// precheck would deny the granted retry as "already consumed". The single
// consumption happens in executionVerdictWorkerCallInner below — the stage
// that actually permits execution — where the same mutex still serializes
// concurrent identical retries to exactly one.
if (verdict.effect === "allow") return verdict;
if (verdict.effect !== "ask") return verdict;
return { effect: "deny", reason: workerUnresolvedAskReason(verdict.request) };
return denyWorkerCallWithEnvelope(
grantOptions,
call,
verdict.request,
workerCwd,
workerUnresolvedAskReason(verdict.request),
);
}

async function evaluateWorkerCall(
gate: PermissionGate,
call: ToolCallType,
grantOptions?: WorkerGrantOptions,
): Promise<GateVerdict> {
const verdict = await authorizeWorkerCall(gate, call);
const verdict = await authorizeWorkerCall(gate, call, grantOptions);
if (verdict.effect === "allow") return { allowed: true };
return { allowed: false, reason: verdict.reason };
}

async function executionVerdictWorkerCall(
gate: PermissionGate,
call: ToolCallType,
grantOptions?: WorkerGrantOptions,
cwd?: string,
): Promise<AuthorizeVerdict> {
if (isWorkerControlPlaneTool(call.name)) return { effect: "allow" };
const workerCwd = resolveWorkerCwd(cwd);
const sessionId = resolveWorkerSessionId(grantOptions?.sessionId);
if (sessionId === undefined)
return executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd);
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
return store.runExclusive(workerGrantKey(sessionId, call, workerCwd), () =>
executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd, {
sessionId,
store,
}),
);
}

async function executionVerdictWorkerCallInner(
gate: PermissionGate,
call: ToolCallType,
grantOptions: WorkerGrantOptions | undefined,
workerCwd: string,
grant?: { sessionId: string; store: WorkerGrantStore },
): Promise<AuthorizeVerdict> {
if (grant !== undefined) {
const precheck = grant.store.precheck(
workerCallIdentity(grant.sessionId, call, workerCwd),
);
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
}
const verdict = await gate.executionVerdict(call);
// Sole consumption point: the execution backstop spends the envelope when
// the exact call is allowed, so one grant request permits exactly one
// execution. Authorize deliberately does not consume (see above).
if (verdict.effect === "allow") {
if (grant !== undefined) {
grant.store.consumeOnAllow(
workerCallIdentity(grant.sessionId, call, workerCwd),
);
}
return verdict;
}
if (verdict.effect !== "ask") return verdict;
return { effect: "deny", reason: workerUnresolvedAskReason(verdict.request) };
return denyWorkerCallWithEnvelope(
grantOptions,
call,
verdict.request,
workerCwd,
workerUnresolvedAskReason(verdict.request),
);
}

// Shared-policy view for worker posix/MCP plugins and reactor authz: live
// grants stay on the parent, reactor-gated middleware is `isReactorGated()`,
// and authorizeCall never emits ask.
export function workerPermissionGate(gate: PermissionGate): PermissionGate {
export function workerPermissionGate(
gate: PermissionGate,
grantOptions?: WorkerGrantOptions,
): PermissionGate {
return {
evaluate: (call) => evaluateWorkerCall(gate, call),
authorizeCall: (call) => authorizeWorkerCall(gate, call),
executionVerdict: (call) => executionVerdictWorkerCall(gate, call),
evaluate: (call) => evaluateWorkerCall(gate, call, grantOptions),
authorizeCall: (call) => authorizeWorkerCall(gate, call, grantOptions),
executionVerdict: (call) =>
executionVerdictWorkerCall(gate, call, grantOptions),
resolveSuspended: (request) => gate.resolveSuspended(request),
isReactorGated: () => true,
getApprovals: () => gate.getApprovals(),
Expand Down Expand Up @@ -159,12 +355,13 @@ export function createReactorAuthorize(

export function createWorkerAuthorize(
gate: PermissionGate,
grantOptions?: WorkerGrantOptions,
): (
resource: string,
action: string,
context: unknown,
) => Promise<AuthzCallResult> {
const workerGate = workerPermissionGate(gate);
const workerGate = workerPermissionGate(gate, grantOptions);
return async (resource, action, context) => {
const call = readAuthorizeToolCall(resource, action, context);
const verdict = await workerGate.authorizeCall(call);
Expand Down
Loading
Loading