Skip to content
Open
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
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
218 changes: 210 additions & 8 deletions src/permission/reactor-authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ 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 WorkerDeniedCallEnvelope,
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 +80,235 @@ 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;
/** Observability hook for tests/telemetry; never authoritative. */
onDeniedCall?: (envelope: WorkerDeniedCallEnvelope) => void;
}

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 }
: {}),
}),
);
try {
options?.onDeniedCall?.(envelope);
} catch {
// Observability must not throw into the deny path.
}
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);
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),
);
}

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);
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 +360,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