Skip to content

Commit 476d38e

Browse files
committed
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.
1 parent b3a95ad commit 476d38e

7 files changed

Lines changed: 1289 additions & 20 deletions

File tree

‎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 single-parent-turn expiry. 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 parent observes the envelope on the ask record, 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, expiry, decline, or interrupt fail closed with a truthful blocker. There is no dedicated grant verb — single-use and exactness are enforced by the envelope sidecar, not by tool plumbing.
107+
104108
### TUI Runner (`src/tui/runner/`)
105109

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

‎src/permission/reactor-authorize.ts‎

Lines changed: 162 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+
formatWorkerDenyWithGrantId,
27+
getProcessWorkerGrantStore,
28+
type WorkerDeniedCallEnvelope,
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,188 @@ 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+
/** Observability hook for tests/telemetry; never authoritative. */
90+
onDeniedCall?: (envelope: WorkerDeniedCallEnvelope) => void;
91+
}
92+
93+
function resolveWorkerSessionId(
94+
sessionId: WorkerGrantOptions["sessionId"],
95+
): string | undefined {
96+
return typeof sessionId === "function" ? sessionId() : sessionId;
97+
}
98+
99+
function resolveWorkerCwd(cwd: string | undefined): string {
100+
return cwd ?? getSubAgentIdentity()?.cwd ?? process.cwd();
101+
}
102+
103+
function workerCallIdentity(
104+
sessionId: string,
105+
call: ToolCallType,
106+
cwd: string,
107+
): {
108+
sessionId: string;
109+
canonicalTool: string;
110+
args: Record<string, unknown>;
111+
cwd: string;
112+
} {
113+
return {
114+
sessionId,
115+
canonicalTool: canonicalToolName(call.name),
116+
args: (call.arguments ?? {}) as Record<string, unknown>,
117+
cwd,
118+
};
119+
}
120+
121+
/**
122+
* Harness-owned denied-call sidecar (CL-9475 Phase 1): on a worker
123+
* deny-on-ask, register the exact denied call and name only its requestId in
124+
* the deny reason. Reuses a still-pending envelope for the same exact call
125+
* instead of minting duplicates across reactor retries with fresh call ids.
126+
*/
127+
function denyWorkerCallWithEnvelope(
128+
options: WorkerGrantOptions | undefined,
129+
call: ToolCallType,
130+
request: PermissionRequest,
131+
cwd: string,
132+
baseReason: string,
133+
): { effect: "deny"; reason: string } {
134+
const sessionId =
135+
options !== undefined
136+
? resolveWorkerSessionId(options.sessionId)
137+
: undefined;
138+
if (sessionId === undefined) return { effect: "deny", reason: baseReason };
139+
const store = options?.store ?? getProcessWorkerGrantStore();
140+
const identity = workerCallIdentity(sessionId, call, cwd);
141+
const existing = store.pendingMatch(
142+
identity.sessionId,
143+
identity.canonicalTool,
144+
identity.args,
145+
identity.cwd,
146+
);
147+
const envelope =
148+
existing ??
149+
store.register(
150+
createDeniedCallEnvelope({
151+
callId: call.id,
152+
tool: call.name,
153+
action: request.action,
154+
subject: request.subject,
155+
args: identity.args,
156+
cwd: identity.cwd,
157+
workerSessionId: identity.sessionId,
158+
...(options?.workspaceRoot !== undefined
159+
? { workspaceRoot: options.workspaceRoot }
160+
: {}),
161+
}),
162+
);
163+
try {
164+
options?.onDeniedCall?.(envelope);
165+
} catch {
166+
// Observability must not throw into the deny path.
167+
}
168+
return {
169+
effect: "deny",
170+
reason: formatWorkerDenyWithGrantId(baseReason, envelope.requestId),
171+
};
172+
}
173+
74174
async function authorizeWorkerCall(
75175
gate: PermissionGate,
76176
call: ToolCallType,
177+
grantOptions?: WorkerGrantOptions,
178+
cwd?: string,
77179
): Promise<{ effect: "allow" } | { effect: "deny"; reason: string }> {
78180
if (isWorkerControlPlaneTool(call.name)) return { effect: "allow" };
181+
const workerCwd = resolveWorkerCwd(cwd);
182+
const sessionId = resolveWorkerSessionId(grantOptions?.sessionId);
183+
if (sessionId !== undefined) {
184+
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
185+
const precheck = store.precheck(
186+
workerCallIdentity(sessionId, call, workerCwd),
187+
);
188+
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
189+
}
79190
const verdict = await gate.authorizeCall(call);
191+
if (verdict.effect === "allow") {
192+
if (sessionId !== undefined) {
193+
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
194+
store.consumeOnAllow(workerCallIdentity(sessionId, call, workerCwd));
195+
}
196+
return verdict;
197+
}
80198
if (verdict.effect !== "ask") return verdict;
81-
return { effect: "deny", reason: workerUnresolvedAskReason(verdict.request) };
199+
return denyWorkerCallWithEnvelope(
200+
grantOptions,
201+
call,
202+
verdict.request,
203+
workerCwd,
204+
workerUnresolvedAskReason(verdict.request),
205+
);
82206
}
83207

84208
async function evaluateWorkerCall(
85209
gate: PermissionGate,
86210
call: ToolCallType,
211+
grantOptions?: WorkerGrantOptions,
87212
): Promise<GateVerdict> {
88-
const verdict = await authorizeWorkerCall(gate, call);
213+
const verdict = await authorizeWorkerCall(gate, call, grantOptions);
89214
if (verdict.effect === "allow") return { allowed: true };
90215
return { allowed: false, reason: verdict.reason };
91216
}
92217

93218
async function executionVerdictWorkerCall(
94219
gate: PermissionGate,
95220
call: ToolCallType,
221+
grantOptions?: WorkerGrantOptions,
222+
cwd?: string,
96223
): Promise<AuthorizeVerdict> {
97224
if (isWorkerControlPlaneTool(call.name)) return { effect: "allow" };
225+
const workerCwd = resolveWorkerCwd(cwd);
226+
const sessionId = resolveWorkerSessionId(grantOptions?.sessionId);
227+
if (sessionId !== undefined) {
228+
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
229+
const precheck = store.precheck(
230+
workerCallIdentity(sessionId, call, workerCwd),
231+
);
232+
if (!precheck.ok) return { effect: "deny", reason: precheck.blocker };
233+
}
98234
const verdict = await gate.executionVerdict(call);
235+
if (verdict.effect === "allow") {
236+
if (sessionId !== undefined) {
237+
const store = grantOptions?.store ?? getProcessWorkerGrantStore();
238+
store.consumeOnAllow(workerCallIdentity(sessionId, call, workerCwd));
239+
}
240+
return verdict;
241+
}
99242
if (verdict.effect !== "ask") return verdict;
100-
return { effect: "deny", reason: workerUnresolvedAskReason(verdict.request) };
243+
return denyWorkerCallWithEnvelope(
244+
grantOptions,
245+
call,
246+
verdict.request,
247+
workerCwd,
248+
workerUnresolvedAskReason(verdict.request),
249+
);
101250
}
102251

103252
// Shared-policy view for worker posix/MCP plugins and reactor authz: live
104253
// grants stay on the parent, reactor-gated middleware is `isReactorGated()`,
105254
// and authorizeCall never emits ask.
106-
export function workerPermissionGate(gate: PermissionGate): PermissionGate {
255+
export function workerPermissionGate(
256+
gate: PermissionGate,
257+
grantOptions?: WorkerGrantOptions,
258+
): PermissionGate {
107259
return {
108-
evaluate: (call) => evaluateWorkerCall(gate, call),
109-
authorizeCall: (call) => authorizeWorkerCall(gate, call),
110-
executionVerdict: (call) => executionVerdictWorkerCall(gate, call),
260+
evaluate: (call) => evaluateWorkerCall(gate, call, grantOptions),
261+
authorizeCall: (call) => authorizeWorkerCall(gate, call, grantOptions),
262+
executionVerdict: (call) =>
263+
executionVerdictWorkerCall(gate, call, grantOptions),
111264
resolveSuspended: (request) => gate.resolveSuspended(request),
112265
isReactorGated: () => true,
113266
getApprovals: () => gate.getApprovals(),
@@ -159,12 +312,13 @@ export function createReactorAuthorize(
159312

160313
export function createWorkerAuthorize(
161314
gate: PermissionGate,
315+
grantOptions?: WorkerGrantOptions,
162316
): (
163317
resource: string,
164318
action: string,
165319
context: unknown,
166320
) => Promise<AuthzCallResult> {
167-
const workerGate = workerPermissionGate(gate);
321+
const workerGate = workerPermissionGate(gate, grantOptions);
168322
return async (resource, action, context) => {
169323
const call = readAuthorizeToolCall(resource, action, context);
170324
const verdict = await workerGate.authorizeCall(call);

0 commit comments

Comments
 (0)