Skip to content

Commit 891f013

Browse files
committed
Attribute approval prompts to the sub-agent requesting them
Each sub-agent's tool calls now run under its own identity (dispatch description + cwd) in async-local storage, so a permission request raised from a sub-agent carries that agent's label instead of looking like a top-level request. The approval modal shows the requesting agent and its working directory prominently, and the queued-behind list shows every other pending approval with a per-agent color tag so requests from different agents read as visually distinct.
1 parent 66631b5 commit 891f013

9 files changed

Lines changed: 191 additions & 6 deletions

File tree

src/permission/gate.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { isApproved, matchesPattern, escapeGlobLiteral } from "./matcher.js";
1616
import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js";
1717
import { createPathRestriction } from "./path-restriction.js";
1818
import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js";
19+
import { getSubAgentIdentity } from "../subagent/identity-context.js";
1920
import {
2021
createMcpToolPermissionRegistry,
2122
registerMcpClientTools,
@@ -326,8 +327,17 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
326327
// blanket-allowed; fall through to the operator prompt below.
327328
}
328329

330+
// A sub-agent's own tool calls run under its identity in ALS (see
331+
// identity-context.ts, wired from subagent/run.ts). When present, the
332+
// prompt is attributed to that sub-agent instead of the top-level session.
333+
const subAgentIdentity = getSubAgentIdentity();
334+
const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd;
329335
for (const rawRequest of buildRequests(call)) {
330-
const request: typeof rawRequest = { ...rawRequest, cwd: resolvedCwd };
336+
const request: typeof rawRequest = {
337+
...rawRequest,
338+
cwd: effectiveCwd,
339+
...(subAgentIdentity !== undefined ? { agentLabel: subAgentIdentity.description } : {}),
340+
};
331341
// Shell: security still splits the chain, but the operator sees (and
332342
// accepts/rejects) the full command once. Any unapproved segment fails the
333343
// whole block. Execution always runs the full string the model asked for.
@@ -365,7 +375,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
365375
!fullReferencesSecret &&
366376
!commandTargetsRestricted(fullCommand, isRestricted) &&
367377
segments.length > 1 &&
368-
hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, resolvedCwd)
378+
hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, effectiveCwd)
369379
) {
370380
continue;
371381
}
@@ -385,7 +395,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
385395
needsOperator = true;
386396
continue;
387397
}
388-
if (isApproved(request.tool, segment, approvals, activeProviderModel, resolvedCwd)) {
398+
if (isApproved(request.tool, segment, approvals, activeProviderModel, effectiveCwd)) {
389399
continue;
390400
}
391401
// Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip.
@@ -433,7 +443,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
433443
const alreadyApproved =
434444
// Path-arg tools already drop to ask via callTargetsRestricted; grants
435445
// match on the path subject the same as before.
436-
isApproved(request.tool, request.subject, approvals, activeProviderModel, resolvedCwd);
446+
isApproved(request.tool, request.subject, approvals, activeProviderModel, effectiveCwd);
437447
if (alreadyApproved) {
438448
continue;
439449
}

src/permission/permission.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2743,3 +2743,67 @@ describe("deriveCommandScopes comment insensitivity", () => {
27432743
expect(withComment).toEqual(withoutComment);
27442744
});
27452745
});
2746+
2747+
describe("sub-agent identity on permission requests", () => {
2748+
test("a request raised outside any sub-agent carries no agentLabel", async () => {
2749+
let seen: PermissionRequest | undefined;
2750+
const gate = createPermissionGate({
2751+
approvals: [],
2752+
requestApproval: async (request) => {
2753+
seen = request;
2754+
return { allow: true };
2755+
},
2756+
interactive: true,
2757+
skipPermissions: false,
2758+
cwd: "/repo",
2759+
});
2760+
await gate.evaluate(shellCall("npm test"));
2761+
expect(seen?.agentLabel).toBeUndefined();
2762+
expect(seen?.cwd).toBe("/repo");
2763+
});
2764+
2765+
test("a request raised from a sub-agent's own tool call carries its identity", async () => {
2766+
const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js");
2767+
let seen: PermissionRequest | undefined;
2768+
const gate = createPermissionGate({
2769+
approvals: [],
2770+
requestApproval: async (request) => {
2771+
seen = request;
2772+
return { allow: true };
2773+
},
2774+
interactive: true,
2775+
skipPermissions: false,
2776+
cwd: "/repo",
2777+
});
2778+
await runWithSubAgentIdentity({ description: "Fix flaky test", cwd: "/repo" }, () =>
2779+
gate.evaluate(shellCall("npm test")),
2780+
);
2781+
expect(seen?.agentLabel).toBe("Fix flaky test");
2782+
expect(seen?.cwd).toBe("/repo");
2783+
});
2784+
2785+
test("identity does not leak across concurrent calls without an active ALS scope", async () => {
2786+
const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js");
2787+
const seen: (PermissionRequest | undefined)[] = [];
2788+
const gate = createPermissionGate({
2789+
approvals: [],
2790+
requestApproval: async (request) => {
2791+
seen.push(request);
2792+
return { allow: true };
2793+
},
2794+
interactive: true,
2795+
skipPermissions: false,
2796+
cwd: "/repo",
2797+
});
2798+
await Promise.all([
2799+
runWithSubAgentIdentity({ description: "Worker A", cwd: "/repo" }, () =>
2800+
gate.evaluate(shellCall("npm run a")),
2801+
),
2802+
gate.evaluate(shellCall("npm run b")),
2803+
]);
2804+
const withA = seen.find((r) => r?.subject === "npm run a");
2805+
const withoutLabel = seen.find((r) => r?.subject === "npm run b");
2806+
expect(withA?.agentLabel).toBe("Worker A");
2807+
expect(withoutLabel?.agentLabel).toBeUndefined();
2808+
});
2809+
});

src/permission/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export type PermissionRequest = {
4343
// The workspace root this request was raised from. Used to confine
4444
// project-scoped grant reconciliation to the repo the grant was minted in.
4545
cwd?: string;
46+
// The requesting sub-agent's label (its dispatch description), when this
47+
// request originated from a sub-agent's own tool call rather than the
48+
// top-level session. Undefined for top-level requests.
49+
agentLabel?: string;
4650
// A single muted-line explanation shown to the operator when scopes were
4751
// withheld for a reason beyond the ordinary "no persistent option exists
4852
// yet" case (e.g. a mega-chain that only offers accept-once). Plain literal

src/subagent/identity-context.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
3+
// Identifies which sub-agent a tool call belongs to, so the permission gate
4+
// can attribute an approval prompt to the agent that raised it (its dispatch
5+
// description) and the working directory it is operating in. Set once per
6+
// sub-agent around its own tool-call dispatch (see run.ts's toolsFactory) so
7+
// every awaited call within that sub-agent's turn — including the permission
8+
// gate and its operator prompt — can read it back via getSubAgentIdentity().
9+
export type SubAgentIdentity = { description: string; cwd: string };
10+
11+
const subAgentIdentityAls = new AsyncLocalStorage<SubAgentIdentity>();
12+
13+
export function runWithSubAgentIdentity<T>(
14+
identity: SubAgentIdentity,
15+
fn: () => Promise<T>,
16+
): Promise<T> {
17+
return subAgentIdentityAls.run(identity, fn);
18+
}
19+
20+
export function getSubAgentIdentity(): SubAgentIdentity | undefined {
21+
return subAgentIdentityAls.getStore();
22+
}

src/subagent/run.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787
} from "./dispose.js";
8888
import { createTaskTool } from "./task-tool.js";
8989
import type { RunSubAgentParams, SubAgentProvider } from "./types.js";
90+
import { runWithSubAgentIdentity } from "./identity-context.js";
9091

9192
export type {
9293
NestedDispatchDeps,
@@ -415,11 +416,21 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise<string> {
415416
stallWatchdog = setInterval(() => requestContinuation(), modelFamilyPolicy.subAgentStallTimeoutMs);
416417
if (typeof stallWatchdog.unref === "function") stallWatchdog.unref();
417418

419+
// Every tool call this sub-agent makes runs under its own identity in ALS
420+
// (description + cwd), so the permission gate can attribute an approval
421+
// prompt to the sub-agent that raised it (see identity-context.ts).
422+
const subAgentIdentity = { description: params.description, cwd: params.cwd };
418423
const toolsFactory = defineTool({
419424
id: `${ID_PREFIX}/subagent-tools`,
420425
// Without the watchdog config, child tool calls run under default budgets
421426
// and ignore tools.timeoutMs / maxTimeoutMs / waitForApproval settings.
422-
factory: () => createDynamicToolRunner(tools, toolWatchdogFromSettings(params.settings)),
427+
factory: () => {
428+
const runner = createDynamicToolRunner(tools, toolWatchdogFromSettings(params.settings));
429+
return {
430+
...runner,
431+
run: (call, signal) => runWithSubAgentIdentity(subAgentIdentity, () => runner.run(call, signal)),
432+
};
433+
},
423434
});
424435

425436
const workdir = join(params.workdirBase, "subagents", generateSessionId());

src/tui/app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@ export function App({
11261126
onReject={gates.reject}
11271127
onSelectOperator={gates.selectOperator}
11281128
permissionQueueDepth={gates.permissionQueueDepth}
1129+
queuedApprovals={gates.queuedApprovals}
11291130
onResolvePermission={gates.resolvePermission}
11301131

11311132
width={columns}

src/tui/components/modal-stack.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
33
import type { LifecycleHookStatus } from "../../session/hooks.js";
44
import type { ApprovalOutcome } from "../../permission/types.js";
55
import type { PlanStep } from "../use-stream.js";
6-
import type { ActiveApproval } from "../hooks/use-gates.js";
6+
import type { ActiveApproval, QueuedApprovalSummary } from "../hooks/use-gates.js";
77
import { HookPanel } from "./hook-panel.js";
88
import { HelpOverlay } from "./help-overlay.js";
99
import { AgentModal, toAgentProviders, type AgentProvider, type ProviderFormSubmission } from "./agent-modal.js";
@@ -77,6 +77,7 @@ export type ModalStackProps = {
7777
onReject: (id: number) => void;
7878
onSelectOperator: (id: number, result: OperatorResult) => void;
7979
permissionQueueDepth?: number;
80+
queuedApprovals?: readonly QueuedApprovalSummary[];
8081
onResolvePermission: (id: number, outcome: ApprovalOutcome) => void;
8182

8283

@@ -116,6 +117,7 @@ export function ModalStack({
116117
onReject,
117118
onSelectOperator,
118119
permissionQueueDepth,
120+
queuedApprovals,
119121
onResolvePermission,
120122
width,
121123
}: ModalStackProps): ReactNode {
@@ -172,6 +174,7 @@ export function ModalStack({
172174
key={activeApproval.id}
173175
request={activeApproval.request}
174176
{...(permissionQueueDepth !== undefined ? { permissionQueueDepth } : {})}
177+
{...(queuedApprovals !== undefined ? { queuedApprovals } : {})}
175178
{...(activeApproval.timeoutMs !== null ? { goalTimeoutMs: activeApproval.timeoutMs } : {})}
176179
onResolve={(outcome) => onResolvePermission(activeApproval.id, outcome)}
177180
{...(width !== undefined ? { width } : {})}

src/tui/components/permission-modal.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { stripTerminalControlSequences } from "../../util/control-char-strip.js"
88
import { isShellCommentOnly } from "../../permission/command.js";
99
import { groupChainSegmentsForDisplay, middleEllipsis, verbatimCommandLines } from "../command-display.js";
1010
import type { VerbatimLine } from "../command-display.js";
11+
import type { QueuedApprovalSummary } from "../hooks/use-gates.js";
1112

1213
// Bidi controls (RLO, embeddings, isolates) visually reorder the rendered
1314
// command — Trojan Source — and zero-width characters hide payload boundaries,
@@ -68,10 +69,24 @@ function truncateChoiceText(text: string, width: number): string {
6869
return middleEllipsis(text, budget);
6970
}
7071

72+
// Deterministic color per agent label so queued approvals from different
73+
// sub-agents read as visually distinct without a shared color registry.
74+
const AGENT_TAG_ROLES = ["accent", "success", "warning", "syntaxKeyword", "syntaxFunction", "syntaxType"] as const;
75+
76+
function agentTagColor(label: string): string {
77+
let hash = 0;
78+
for (let i = 0; i < label.length; i++) hash = (hash * 31 + label.charCodeAt(i)) >>> 0;
79+
return color(AGENT_TAG_ROLES[hash % AGENT_TAG_ROLES.length]!);
80+
}
81+
82+
const MAX_RENDERED_QUEUE_ENTRIES = 5;
83+
7184
export type PermissionModalProps = {
7285
request: PermissionRequest;
7386
/** Permission gates still queued, including this modal. */
7487
permissionQueueDepth?: number;
88+
/** Summary of every queued permission request, for the "queued behind" list. */
89+
queuedApprovals?: readonly QueuedApprovalSummary[];
7590
/**
7691
* When set (goal mode), show that the request auto-skips after this many ms
7792
* if the operator does not answer.
@@ -187,11 +202,16 @@ function descriptorArgs(request: PermissionRequest): Record<string, unknown> {
187202
export function PermissionModal({
188203
request,
189204
permissionQueueDepth = 1,
205+
queuedApprovals = [],
190206
goalTimeoutMs = null,
191207
onResolve,
192208
width = 80,
193209
}: PermissionModalProps): ReactNode {
194210
const queuedBehind = Math.max(0, permissionQueueDepth - 1);
211+
// Everything behind the currently visible entry, distinguished by agent.
212+
const otherQueued = queuedApprovals.slice(1);
213+
const shownQueued = otherQueued.slice(0, MAX_RENDERED_QUEUE_ENTRIES);
214+
const hiddenQueuedCount = otherQueued.length - shownQueued.length;
195215
const choices = buildChoices(request);
196216
const [selected, setSelected] = useState(0);
197217
const [message, setMessage] = useState("");
@@ -286,6 +306,14 @@ export function PermissionModal({
286306
width={Math.max(24, width - 2)}
287307
>
288308
<Text bold color={toolColor}>Approval needed</Text>
309+
{request.agentLabel !== undefined && (
310+
<Text>
311+
<Text color={agentTagColor(request.agentLabel)} bold>{`⏺ ${request.agentLabel}`}</Text>
312+
{request.cwd !== undefined && (
313+
<Text color={color("muted")}>{` ${request.cwd}`}</Text>
314+
)}
315+
</Text>
316+
)}
289317
{goalTimeoutSecs !== null && (
290318
<Text color={color("muted")}>
291319
{`Goal mode · auto-skip in ~${goalTimeoutSecs}s if no response`}
@@ -305,6 +333,24 @@ export function PermissionModal({
305333
? ` · +${queuedBehind} more approval${queuedBehind === 1 ? "" : "s"} queued`
306334
: ""}
307335
</Text>
336+
{shownQueued.length > 0 && (
337+
<Box marginLeft={2} flexDirection="column">
338+
{shownQueued.map((entry) => (
339+
<Text key={entry.id} color={color("muted")}>
340+
{"· "}
341+
{entry.agentLabel !== undefined ? (
342+
<Text color={agentTagColor(entry.agentLabel)}>{entry.agentLabel}</Text>
343+
) : (
344+
<Text color={color("muted")}>session</Text>
345+
)}
346+
{` — ${entry.tool}`}
347+
</Text>
348+
))}
349+
{hiddenQueuedCount > 0 && (
350+
<Text color={color("muted")}>{`… ${hiddenQueuedCount} more waiting`}</Text>
351+
)}
352+
</Box>
353+
)}
308354
{descriptor.isShell && (
309355
// The exact string that will execute, always shown verbatim: the
310356
// segment list below is a lossy reconstruction, and the scope hints

src/tui/hooks/use-gates.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,17 @@ export type ActiveApproval =
4040
| { id: number; kind: "operator"; question: string; options: string[] }
4141
| { id: number; kind: "permission"; request: PermissionRequest; timeoutMs: number | null };
4242

43+
// One line per queued (not just visible) permission request, so the modal can
44+
// show that other approvals are waiting and which agent each belongs to —
45+
// distinct agentLabel values render as visually distinct entries.
46+
export type QueuedApprovalSummary = { id: number; tool: string; agentLabel?: string };
47+
4348
export type GateController = {
4449
activeApproval: ActiveApproval | null;
4550
/** Permission gates still queued, including the visible modal. */
4651
permissionQueueDepth: number;
52+
/** Summary of every queued (not just visible) permission request. */
53+
queuedApprovals: readonly QueuedApprovalSummary[];
4754
gateOpen: boolean;
4855
approve: (id: number) => void;
4956
reject: (id: number) => void;
@@ -132,7 +139,20 @@ export function useGates({
132139
}: UseGatesArgs): GateController {
133140
const [activeApproval, setActiveApproval] = useState<ActiveApproval | null>(null);
134141
const [permissionQueueDepth, setPermissionQueueDepth] = useState(0);
142+
const [queuedApprovals, setQueuedApprovals] = useState<readonly QueuedApprovalSummary[]>([]);
135143
const queue = useRef<GateQueueEntry[]>([]);
144+
145+
function syncQueuedApprovals(): void {
146+
setQueuedApprovals(
147+
queue.current
148+
.filter((e): e is PermissionQueueEntry => e.kind === "permission")
149+
.map((e) => ({
150+
id: e.id,
151+
tool: e.request.tool,
152+
...(e.request.agentLabel !== undefined ? { agentLabel: e.request.agentLabel } : {}),
153+
})),
154+
);
155+
}
136156
const nextId = useRef(1);
137157
const activeId = useRef<number | null>(null);
138158
const activationBlockedRef = useRef(activationBlocked);
@@ -175,6 +195,7 @@ export function useGates({
175195
detachEntryAbort(entry);
176196
if (entry.kind === "permission") {
177197
setPermissionQueueDepth((depth) => Math.max(0, depth - 1));
198+
syncQueuedApprovals();
178199
}
179200
setGatePendingRef.current(false);
180201
if (index === 0) {
@@ -225,6 +246,7 @@ export function useGates({
225246
queue.current.push(entry);
226247
if (entry.kind === "permission") {
227248
setPermissionQueueDepth((depth) => depth + 1);
249+
syncQueuedApprovals();
228250
}
229251
setGatePendingRef.current(true);
230252
if (queue.current.length === 1) updateVisibleEntry();
@@ -235,6 +257,7 @@ export function useGates({
235257
activeId.current = null;
236258
setActiveApproval(null);
237259
setPermissionQueueDepth(0);
260+
setQueuedApprovals([]);
238261
for (const entry of remaining) {
239262
clearEntryTimer(entry);
240263
detachEntryAbort(entry);
@@ -332,6 +355,7 @@ export function useGates({
332355
return {
333356
activeApproval,
334357
permissionQueueDepth,
358+
queuedApprovals,
335359
gateOpen: activeApproval !== null,
336360
approve: (id) => settlePlan(id, true),
337361
reject: (id) => settlePlan(id, false),

0 commit comments

Comments
 (0)