Skip to content

Commit cb3e3e6

Browse files
feat(permission): add harness-owned worker grant envelope (#1222)
* feat(permission): add harness-owned worker grant envelope A worker deny-on-ask registers a denied-call envelope keyed by worker session with single-parent-turn expiry. The deny reason names only the envelope requestId and the parent retries via resume_agent with exact args. * fix(permission): bind ask to its denial, expire open, consume once Ask names its denial via grant_request_id; unnamed keeps first-pending. Expiry yields a fresh gate round that mints a fresh envelope. Precheck-to-consume serializes per fingerprint. * fix(subagent): thread grant_request_id through ask_director leaf * test(subagent): follow test-helper move to repo-root testkit origin/main relocated shared test helpers from tests/helpers to testkit; the grant threading test follows the mock-module import. * test(permission): cover envelope reuse across reactor retries Two denies of the same exact call under fresh call ids name the same grant request instead of prompting the parent twice. * fix(permission): spend worker grant at execution, session-scope precheck Authorize leaves the envelope pending so the granted retry survives the execution backstop, which now spends it exactly once; precheck no longer vetoes sibling sessions, and unwired helpers are removed. * test(permission): keep two-stage execute and per-session envelopes
1 parent 84a2ead commit cb3e3e6

11 files changed

Lines changed: 1878 additions & 21 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ OAuth stores serialize refresh writes and treat the value observed under the sto
101101

102102
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.
103103

104+
### Worker permission grants
105+
106+
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.
107+
104108
### TUI Runner (`src/tui/runner/`)
105109

106110
- Builds a chat-mode agent using the `ChatDirector`

‎src/permission/reactor-authorize.ts‎

Lines changed: 205 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ import type { AuthzCallResult } from "@intx/inference";
2121
import { WORKER_CANNOT_COMPLETE_APPROVAL } from "./decline-markers.js";
2222
import type { AuthorizeVerdict, GateVerdict, PermissionGate } from "./gate.js";
2323
import type { PermissionRequest } from "./types.js";
24+
import {
25+
createDeniedCallEnvelope,
26+
fingerprintDeniedCall,
27+
formatWorkerDenyWithGrantId,
28+
getProcessWorkerGrantStore,
29+
type WorkerGrantStore,
30+
} from "./worker-grant.js";
2431
import { canonicalToolName } from "../agent/canonical-tool-name.js";
32+
import { getSubAgentIdentity } from "../subagent/identity-context.js";
2533
import {
2634
FLEET_VERBS,
2735
ORCHESTRATOR_ONLY_FLEET_VERBS,
@@ -71,43 +79,231 @@ function readAuthorizeToolCall(
7179
return call satisfies ToolCallType;
7280
}
7381

82+
export interface WorkerGrantOptions {
83+
/** Defaults to the process-shared sidecar (worker deny side registers,
84+
* parent observes/consumes). Tests pass an isolated store. */
85+
store?: WorkerGrantStore;
86+
/** Owning worker session; absent means no envelope is minted or matched. */
87+
sessionId?: string | (() => string | undefined);
88+
workspaceRoot?: string;
89+
}
90+
91+
function resolveWorkerSessionId(
92+
sessionId: WorkerGrantOptions["sessionId"],
93+
): string | undefined {
94+
return typeof sessionId === "function" ? sessionId() : sessionId;
95+
}
96+
97+
function resolveWorkerCwd(cwd: string | undefined): string {
98+
return cwd ?? getSubAgentIdentity()?.cwd ?? process.cwd();
99+
}
100+
101+
function workerCallIdentity(
102+
sessionId: string,
103+
call: ToolCallType,
104+
cwd: string,
105+
): {
106+
sessionId: string;
107+
canonicalTool: string;
108+
args: Record<string, unknown>;
109+
cwd: string;
110+
} {
111+
return {
112+
sessionId,
113+
canonicalTool: canonicalToolName(call.name),
114+
args: (call.arguments ?? {}) as Record<string, unknown>,
115+
cwd,
116+
};
117+
}
118+
119+
/**
120+
* Harness-owned denied-call sidecar (CL-9475 Phase 1): on a worker
121+
* deny-on-ask, register the exact denied call and name only its requestId in
122+
* the deny reason. Reuses a still-pending envelope for the same exact call
123+
* instead of minting duplicates across reactor retries with fresh call ids.
124+
*/
125+
function denyWorkerCallWithEnvelope(
126+
options: WorkerGrantOptions | undefined,
127+
call: ToolCallType,
128+
request: PermissionRequest,
129+
cwd: string,
130+
baseReason: string,
131+
): { effect: "deny"; reason: string } {
132+
const sessionId =
133+
options !== undefined
134+
? resolveWorkerSessionId(options.sessionId)
135+
: undefined;
136+
if (sessionId === undefined) return { effect: "deny", reason: baseReason };
137+
const store = options?.store ?? getProcessWorkerGrantStore();
138+
const identity = workerCallIdentity(sessionId, call, cwd);
139+
const existing = store.pendingMatch(
140+
identity.sessionId,
141+
identity.canonicalTool,
142+
identity.args,
143+
identity.cwd,
144+
);
145+
const envelope =
146+
existing ??
147+
store.register(
148+
createDeniedCallEnvelope({
149+
callId: call.id,
150+
tool: call.name,
151+
action: request.action,
152+
subject: request.subject,
153+
args: identity.args,
154+
cwd: identity.cwd,
155+
workerSessionId: identity.sessionId,
156+
...(options?.workspaceRoot !== undefined
157+
? { workspaceRoot: options.workspaceRoot }
158+
: {}),
159+
}),
160+
);
161+
return {
162+
effect: "deny",
163+
reason: formatWorkerDenyWithGrantId(baseReason, envelope.requestId),
164+
};
165+
}
166+
167+
/** Mutex key covering the envelope match: concurrent identical worker calls
168+
* serialize through precheck-to-consume so one envelope allows exactly once. */
169+
function workerGrantKey(
170+
sessionId: string,
171+
call: ToolCallType,
172+
cwd: string,
173+
): string {
174+
const identity = workerCallIdentity(sessionId, call, cwd);
175+
return `${sessionId}\n${fingerprintDeniedCall(identity.canonicalTool, identity.args, identity.cwd)}`;
176+
}
177+
74178
async function authorizeWorkerCall(
75179
gate: PermissionGate,
76180
call: ToolCallType,
181+
grantOptions?: WorkerGrantOptions,
182+
cwd?: string,
77183
): Promise<{ effect: "allow" } | { effect: "deny"; reason: string }> {
78184
if (isWorkerControlPlaneTool(call.name)) return { effect: "allow" };
185+
const workerCwd = resolveWorkerCwd(cwd);
186+
const sessionId = resolveWorkerSessionId(grantOptions?.sessionId);
187+
if (sessionId === undefined)
188+
return authorizeWorkerCallInner(gate, call, grantOptions, workerCwd);
189+
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
190+
return store.runExclusive(workerGrantKey(sessionId, call, workerCwd), () =>
191+
authorizeWorkerCallInner(gate, call, grantOptions, workerCwd, {
192+
sessionId,
193+
store,
194+
}),
195+
);
196+
}
197+
198+
async function authorizeWorkerCallInner(
199+
gate: PermissionGate,
200+
call: ToolCallType,
201+
grantOptions: WorkerGrantOptions | undefined,
202+
workerCwd: string,
203+
grant?: { sessionId: string; store: WorkerGrantStore },
204+
): Promise<{ effect: "allow" } | { effect: "deny"; reason: string }> {
205+
if (grant !== undefined) {
206+
const precheck = grant.store.precheck(
207+
workerCallIdentity(grant.sessionId, call, workerCwd),
208+
);
209+
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
210+
}
79211
const verdict = await gate.authorizeCall(call);
212+
// Authorize never consumes: it only permits the call to proceed to the
213+
// tool-runner middleware, which enforces executionVerdict. Consuming here
214+
// would spend the envelope before execution, so the execution backstop's
215+
// precheck would deny the granted retry as "already consumed". The single
216+
// consumption happens in executionVerdictWorkerCallInner below — the stage
217+
// that actually permits execution — where the same mutex still serializes
218+
// concurrent identical retries to exactly one.
219+
if (verdict.effect === "allow") return verdict;
80220
if (verdict.effect !== "ask") return verdict;
81-
return { effect: "deny", reason: workerUnresolvedAskReason(verdict.request) };
221+
return denyWorkerCallWithEnvelope(
222+
grantOptions,
223+
call,
224+
verdict.request,
225+
workerCwd,
226+
workerUnresolvedAskReason(verdict.request),
227+
);
82228
}
83229

84230
async function evaluateWorkerCall(
85231
gate: PermissionGate,
86232
call: ToolCallType,
233+
grantOptions?: WorkerGrantOptions,
87234
): Promise<GateVerdict> {
88-
const verdict = await authorizeWorkerCall(gate, call);
235+
const verdict = await authorizeWorkerCall(gate, call, grantOptions);
89236
if (verdict.effect === "allow") return { allowed: true };
90237
return { allowed: false, reason: verdict.reason };
91238
}
92239

93240
async function executionVerdictWorkerCall(
94241
gate: PermissionGate,
95242
call: ToolCallType,
243+
grantOptions?: WorkerGrantOptions,
244+
cwd?: string,
96245
): Promise<AuthorizeVerdict> {
97246
if (isWorkerControlPlaneTool(call.name)) return { effect: "allow" };
247+
const workerCwd = resolveWorkerCwd(cwd);
248+
const sessionId = resolveWorkerSessionId(grantOptions?.sessionId);
249+
if (sessionId === undefined)
250+
return executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd);
251+
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
252+
return store.runExclusive(workerGrantKey(sessionId, call, workerCwd), () =>
253+
executionVerdictWorkerCallInner(gate, call, grantOptions, workerCwd, {
254+
sessionId,
255+
store,
256+
}),
257+
);
258+
}
259+
260+
async function executionVerdictWorkerCallInner(
261+
gate: PermissionGate,
262+
call: ToolCallType,
263+
grantOptions: WorkerGrantOptions | undefined,
264+
workerCwd: string,
265+
grant?: { sessionId: string; store: WorkerGrantStore },
266+
): Promise<AuthorizeVerdict> {
267+
if (grant !== undefined) {
268+
const precheck = grant.store.precheck(
269+
workerCallIdentity(grant.sessionId, call, workerCwd),
270+
);
271+
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
272+
}
98273
const verdict = await gate.executionVerdict(call);
274+
// Sole consumption point: the execution backstop spends the envelope when
275+
// the exact call is allowed, so one grant request permits exactly one
276+
// execution. Authorize deliberately does not consume (see above).
277+
if (verdict.effect === "allow") {
278+
if (grant !== undefined) {
279+
grant.store.consumeOnAllow(
280+
workerCallIdentity(grant.sessionId, call, workerCwd),
281+
);
282+
}
283+
return verdict;
284+
}
99285
if (verdict.effect !== "ask") return verdict;
100-
return { effect: "deny", reason: workerUnresolvedAskReason(verdict.request) };
286+
return denyWorkerCallWithEnvelope(
287+
grantOptions,
288+
call,
289+
verdict.request,
290+
workerCwd,
291+
workerUnresolvedAskReason(verdict.request),
292+
);
101293
}
102294

103295
// Shared-policy view for worker posix/MCP plugins and reactor authz: live
104296
// grants stay on the parent, reactor-gated middleware is `isReactorGated()`,
105297
// and authorizeCall never emits ask.
106-
export function workerPermissionGate(gate: PermissionGate): PermissionGate {
298+
export function workerPermissionGate(
299+
gate: PermissionGate,
300+
grantOptions?: WorkerGrantOptions,
301+
): PermissionGate {
107302
return {
108-
evaluate: (call) => evaluateWorkerCall(gate, call),
109-
authorizeCall: (call) => authorizeWorkerCall(gate, call),
110-
executionVerdict: (call) => executionVerdictWorkerCall(gate, call),
303+
evaluate: (call) => evaluateWorkerCall(gate, call, grantOptions),
304+
authorizeCall: (call) => authorizeWorkerCall(gate, call, grantOptions),
305+
executionVerdict: (call) =>
306+
executionVerdictWorkerCall(gate, call, grantOptions),
111307
resolveSuspended: (request) => gate.resolveSuspended(request),
112308
isReactorGated: () => true,
113309
getApprovals: () => gate.getApprovals(),
@@ -159,12 +355,13 @@ export function createReactorAuthorize(
159355

160356
export function createWorkerAuthorize(
161357
gate: PermissionGate,
358+
grantOptions?: WorkerGrantOptions,
162359
): (
163360
resource: string,
164361
action: string,
165362
context: unknown,
166363
) => Promise<AuthzCallResult> {
167-
const workerGate = workerPermissionGate(gate);
364+
const workerGate = workerPermissionGate(gate, grantOptions);
168365
return async (resource, action, context) => {
169366
const call = readAuthorizeToolCall(resource, action, context);
170367
const verdict = await workerGate.authorizeCall(call);

0 commit comments

Comments
 (0)