diff --git a/src/permission/gate.ts b/src/permission/gate.ts index c8e9edce7..580416441 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -16,6 +16,7 @@ import { isApproved, matchesPattern, escapeGlobLiteral } from "./matcher.js"; import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js"; import { createPathRestriction } from "./path-restriction.js"; import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js"; +import { getSubAgentIdentity } from "../subagent/identity-context.js"; import { createMcpToolPermissionRegistry, registerMcpClientTools, @@ -326,8 +327,17 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // blanket-allowed; fall through to the operator prompt below. } + // A sub-agent's own tool calls run under its identity in ALS (see + // identity-context.ts, wired from subagent/run.ts). When present, the + // prompt is attributed to that sub-agent instead of the top-level session. + const subAgentIdentity = getSubAgentIdentity(); + const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd; for (const rawRequest of buildRequests(call)) { - const request: typeof rawRequest = { ...rawRequest, cwd: resolvedCwd }; + const request: typeof rawRequest = { + ...rawRequest, + cwd: effectiveCwd, + ...(subAgentIdentity !== undefined ? { agentLabel: subAgentIdentity.description } : {}), + }; // Shell: security still splits the chain, but the operator sees (and // accepts/rejects) the full command once. Any unapproved segment fails the // whole block. Execution always runs the full string the model asked for. @@ -365,7 +375,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission !fullReferencesSecret && !commandTargetsRestricted(fullCommand, isRestricted) && segments.length > 1 && - hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, resolvedCwd) + hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, effectiveCwd) ) { continue; } @@ -385,7 +395,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission needsOperator = true; continue; } - if (isApproved(request.tool, segment, approvals, activeProviderModel, resolvedCwd)) { + if (isApproved(request.tool, segment, approvals, activeProviderModel, effectiveCwd)) { continue; } // Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip. @@ -433,7 +443,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission const alreadyApproved = // Path-arg tools already drop to ask via callTargetsRestricted; grants // match on the path subject the same as before. - isApproved(request.tool, request.subject, approvals, activeProviderModel, resolvedCwd); + isApproved(request.tool, request.subject, approvals, activeProviderModel, effectiveCwd); if (alreadyApproved) { continue; } diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index be8399d0e..c6cef7834 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -2743,3 +2743,98 @@ describe("deriveCommandScopes comment insensitivity", () => { expect(withComment).toEqual(withoutComment); }); }); + +describe("sub-agent identity on permission requests", () => { + test("a request raised outside any sub-agent carries no agentLabel", async () => { + let seen: PermissionRequest | undefined; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async (request) => { + seen = request; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + cwd: "/repo", + }); + await gate.evaluate(shellCall("npm test")); + expect(seen?.agentLabel).toBeUndefined(); + expect(seen?.cwd).toBe("/repo"); + }); + + test("a request raised from a sub-agent's own tool call carries its identity", async () => { + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + let seen: PermissionRequest | undefined; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async (request) => { + seen = request; + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + cwd: "/repo", + }); + await runWithSubAgentIdentity({ description: "Fix flaky test", cwd: "/repo" }, () => + gate.evaluate(shellCall("npm test")), + ); + expect(seen?.agentLabel).toBe("Fix flaky test"); + expect(seen?.cwd).toBe("/repo"); + }); + + test("identity does not leak across concurrent calls without an active ALS scope", async () => { + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const seen: (PermissionRequest | undefined)[] = []; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async (request) => { + seen.push(request); + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + cwd: "/repo", + }); + await Promise.all([ + runWithSubAgentIdentity({ description: "Worker A", cwd: "/repo" }, () => + gate.evaluate(shellCall("npm run a")), + ), + gate.evaluate(shellCall("npm run b")), + ]); + const withA = seen.find((r) => r?.subject === "npm run a"); + const withoutLabel = seen.find((r) => r?.subject === "npm run b"); + expect(withA?.agentLabel).toBe("Worker A"); + expect(withoutLabel?.agentLabel).toBeUndefined(); + }); + + test("two concurrent ALS scopes keep their agent labels isolated", async () => { + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const seen: PermissionRequest[] = []; + const gate = createPermissionGate({ + approvals: [], + requestApproval: async (request) => { + seen.push(request); + // Hold both approvals open so the two scopes truly overlap. + await new Promise((r) => setTimeout(r, 5)); + return { allow: true }; + }, + interactive: true, + skipPermissions: false, + cwd: "/repo", + }); + await Promise.all([ + runWithSubAgentIdentity({ description: "Worker A", cwd: "/repo-a" }, () => + gate.evaluate(shellCall("npm run a")), + ), + runWithSubAgentIdentity({ description: "Worker B", cwd: "/repo-b" }, () => + gate.evaluate(shellCall("npm run b")), + ), + ]); + const withA = seen.find((r) => r.subject === "npm run a"); + const withB = seen.find((r) => r.subject === "npm run b"); + expect(withA?.agentLabel).toBe("Worker A"); + expect(withA?.cwd).toBe("/repo-a"); + expect(withB?.agentLabel).toBe("Worker B"); + expect(withB?.cwd).toBe("/repo-b"); + }); +}); diff --git a/src/permission/types.ts b/src/permission/types.ts index ccc9e584c..44397f5b5 100644 --- a/src/permission/types.ts +++ b/src/permission/types.ts @@ -43,6 +43,10 @@ export type PermissionRequest = { // The workspace root this request was raised from. Used to confine // project-scoped grant reconciliation to the repo the grant was minted in. cwd?: string; + // The requesting sub-agent's label (its dispatch description), when this + // request originated from a sub-agent's own tool call rather than the + // top-level session. Undefined for top-level requests. + agentLabel?: string; // A single muted-line explanation shown to the operator when scopes were // withheld for a reason beyond the ordinary "no persistent option exists // yet" case (e.g. a mega-chain that only offers accept-once). Plain literal diff --git a/src/subagent/identity-context.ts b/src/subagent/identity-context.ts new file mode 100644 index 000000000..13b538029 --- /dev/null +++ b/src/subagent/identity-context.ts @@ -0,0 +1,22 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +// Identifies which sub-agent a tool call belongs to, so the permission gate +// can attribute an approval prompt to the agent that raised it (its dispatch +// description) and the working directory it is operating in. Set once per +// sub-agent around its own tool-call dispatch (see run.ts's toolsFactory) so +// every awaited call within that sub-agent's turn — including the permission +// gate and its operator prompt — can read it back via getSubAgentIdentity(). +export type SubAgentIdentity = { description: string; cwd: string }; + +const subAgentIdentityAls = new AsyncLocalStorage(); + +export function runWithSubAgentIdentity( + identity: SubAgentIdentity, + fn: () => Promise, +): Promise { + return subAgentIdentityAls.run(identity, fn); +} + +export function getSubAgentIdentity(): SubAgentIdentity | undefined { + return subAgentIdentityAls.getStore(); +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index ae69d0778..db5dd7a8e 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -87,6 +87,7 @@ import { } from "./dispose.js"; import { createTaskTool } from "./task-tool.js"; import type { RunSubAgentParams, SubAgentProvider } from "./types.js"; +import { runWithSubAgentIdentity } from "./identity-context.js"; export type { NestedDispatchDeps, @@ -415,11 +416,21 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise { stallWatchdog = setInterval(() => requestContinuation(), modelFamilyPolicy.subAgentStallTimeoutMs); if (typeof stallWatchdog.unref === "function") stallWatchdog.unref(); + // Every tool call this sub-agent makes runs under its own identity in ALS + // (description + cwd), so the permission gate can attribute an approval + // prompt to the sub-agent that raised it (see identity-context.ts). + const subAgentIdentity = { description: params.description, cwd: params.cwd }; const toolsFactory = defineTool({ id: `${ID_PREFIX}/subagent-tools`, // Without the watchdog config, child tool calls run under default budgets // and ignore tools.timeoutMs / maxTimeoutMs / waitForApproval settings. - factory: () => createDynamicToolRunner(tools, toolWatchdogFromSettings(params.settings)), + factory: () => { + const runner = createDynamicToolRunner(tools, toolWatchdogFromSettings(params.settings)); + return { + ...runner, + run: (call, signal) => runWithSubAgentIdentity(subAgentIdentity, () => runner.run(call, signal)), + }; + }, }); const workdir = join(params.workdirBase, "subagents", generateSessionId()); diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 5e4ac6270..ebcb933b1 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -1126,6 +1126,7 @@ export function App({ onReject={gates.reject} onSelectOperator={gates.selectOperator} permissionQueueDepth={gates.permissionQueueDepth} + queuedApprovals={gates.queuedApprovals} onResolvePermission={gates.resolvePermission} width={columns} diff --git a/src/tui/components/modal-stack.tsx b/src/tui/components/modal-stack.tsx index 1f69f456a..70756fd63 100644 --- a/src/tui/components/modal-stack.tsx +++ b/src/tui/components/modal-stack.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import type { LifecycleHookStatus } from "../../session/hooks.js"; import type { ApprovalOutcome } from "../../permission/types.js"; import type { PlanStep } from "../use-stream.js"; -import type { ActiveApproval } from "../hooks/use-gates.js"; +import type { ActiveApproval, QueuedApprovalSummary } from "../hooks/use-gates.js"; import { HookPanel } from "./hook-panel.js"; import { HelpOverlay } from "./help-overlay.js"; import { AgentModal, toAgentProviders, type AgentProvider, type ProviderFormSubmission } from "./agent-modal.js"; @@ -77,6 +77,7 @@ export type ModalStackProps = { onReject: (id: number) => void; onSelectOperator: (id: number, result: OperatorResult) => void; permissionQueueDepth?: number; + queuedApprovals?: readonly QueuedApprovalSummary[]; onResolvePermission: (id: number, outcome: ApprovalOutcome) => void; @@ -116,6 +117,7 @@ export function ModalStack({ onReject, onSelectOperator, permissionQueueDepth, + queuedApprovals, onResolvePermission, width, }: ModalStackProps): ReactNode { @@ -172,6 +174,7 @@ export function ModalStack({ key={activeApproval.id} request={activeApproval.request} {...(permissionQueueDepth !== undefined ? { permissionQueueDepth } : {})} + {...(queuedApprovals !== undefined ? { queuedApprovals } : {})} {...(activeApproval.timeoutMs !== null ? { goalTimeoutMs: activeApproval.timeoutMs } : {})} onResolve={(outcome) => onResolvePermission(activeApproval.id, outcome)} {...(width !== undefined ? { width } : {})} diff --git a/src/tui/components/permission-modal.tsx b/src/tui/components/permission-modal.tsx index c59a208dd..2106bf0b5 100644 --- a/src/tui/components/permission-modal.tsx +++ b/src/tui/components/permission-modal.tsx @@ -8,6 +8,7 @@ import { stripTerminalControlSequences } from "../../util/control-char-strip.js" import { isShellCommentOnly } from "../../permission/command.js"; import { groupChainSegmentsForDisplay, middleEllipsis, verbatimCommandLines } from "../command-display.js"; import type { VerbatimLine } from "../command-display.js"; +import type { QueuedApprovalSummary } from "../hooks/use-gates.js"; // Bidi controls (RLO, embeddings, isolates) visually reorder the rendered // command — Trojan Source — and zero-width characters hide payload boundaries, @@ -68,10 +69,24 @@ function truncateChoiceText(text: string, width: number): string { return middleEllipsis(text, budget); } +// Deterministic color per agent label so queued approvals from different +// sub-agents read as visually distinct without a shared color registry. +const AGENT_TAG_ROLES = ["accent", "success", "warning", "syntaxKeyword", "syntaxFunction", "syntaxType"] as const; + +function agentTagColor(label: string): string { + let hash = 0; + for (let i = 0; i < label.length; i++) hash = (hash * 31 + label.charCodeAt(i)) >>> 0; + return color(AGENT_TAG_ROLES[hash % AGENT_TAG_ROLES.length]!); +} + +const MAX_RENDERED_QUEUE_ENTRIES = 5; + export type PermissionModalProps = { request: PermissionRequest; /** Permission gates still queued, including this modal. */ permissionQueueDepth?: number; + /** Summary of every queued permission request, for the "queued behind" list. */ + queuedApprovals?: readonly QueuedApprovalSummary[]; /** * When set (goal mode), show that the request auto-skips after this many ms * if the operator does not answer. @@ -187,11 +202,16 @@ function descriptorArgs(request: PermissionRequest): Record { export function PermissionModal({ request, permissionQueueDepth = 1, + queuedApprovals = [], goalTimeoutMs = null, onResolve, width = 80, }: PermissionModalProps): ReactNode { const queuedBehind = Math.max(0, permissionQueueDepth - 1); + // Everything behind the currently visible entry, distinguished by agent. + const otherQueued = queuedApprovals.slice(1); + const shownQueued = otherQueued.slice(0, MAX_RENDERED_QUEUE_ENTRIES); + const hiddenQueuedCount = otherQueued.length - shownQueued.length; const choices = buildChoices(request); const [selected, setSelected] = useState(0); const [message, setMessage] = useState(""); @@ -286,6 +306,14 @@ export function PermissionModal({ width={Math.max(24, width - 2)} > Approval needed + {request.agentLabel !== undefined && ( + + {`⏺ ${request.agentLabel}`} + {request.cwd !== undefined && ( + {` ${request.cwd}`} + )} + + )} {goalTimeoutSecs !== null && ( {`Goal mode · auto-skip in ~${goalTimeoutSecs}s if no response`} @@ -305,6 +333,24 @@ export function PermissionModal({ ? ` · +${queuedBehind} more approval${queuedBehind === 1 ? "" : "s"} queued` : ""} + {shownQueued.length > 0 && ( + + {shownQueued.map((entry) => ( + + {"· "} + {entry.agentLabel !== undefined ? ( + {entry.agentLabel} + ) : ( + session + )} + {` — ${entry.tool}`} + + ))} + {hiddenQueuedCount > 0 && ( + {`… ${hiddenQueuedCount} more waiting`} + )} + + )} {descriptor.isShell && ( // The exact string that will execute, always shown verbatim: the // segment list below is a lossy reconstruction, and the scope hints diff --git a/src/tui/hooks/use-gates.ts b/src/tui/hooks/use-gates.ts index e995da10a..cdd2c7a8a 100644 --- a/src/tui/hooks/use-gates.ts +++ b/src/tui/hooks/use-gates.ts @@ -40,10 +40,17 @@ export type ActiveApproval = | { id: number; kind: "operator"; question: string; options: string[] } | { id: number; kind: "permission"; request: PermissionRequest; timeoutMs: number | null }; +// One line per queued (not just visible) permission request, so the modal can +// show that other approvals are waiting and which agent each belongs to — +// distinct agentLabel values render as visually distinct entries. +export type QueuedApprovalSummary = { id: number; tool: string; agentLabel?: string }; + export type GateController = { activeApproval: ActiveApproval | null; /** Permission gates still queued, including the visible modal. */ permissionQueueDepth: number; + /** Summary of every queued (not just visible) permission request. */ + queuedApprovals: readonly QueuedApprovalSummary[]; gateOpen: boolean; approve: (id: number) => void; reject: (id: number) => void; @@ -132,7 +139,20 @@ export function useGates({ }: UseGatesArgs): GateController { const [activeApproval, setActiveApproval] = useState(null); const [permissionQueueDepth, setPermissionQueueDepth] = useState(0); + const [queuedApprovals, setQueuedApprovals] = useState([]); const queue = useRef([]); + + function syncQueuedApprovals(): void { + setQueuedApprovals( + queue.current + .filter((e): e is PermissionQueueEntry => e.kind === "permission") + .map((e) => ({ + id: e.id, + tool: e.request.tool, + ...(e.request.agentLabel !== undefined ? { agentLabel: e.request.agentLabel } : {}), + })), + ); + } const nextId = useRef(1); const activeId = useRef(null); const activationBlockedRef = useRef(activationBlocked); @@ -175,6 +195,7 @@ export function useGates({ detachEntryAbort(entry); if (entry.kind === "permission") { setPermissionQueueDepth((depth) => Math.max(0, depth - 1)); + syncQueuedApprovals(); } setGatePendingRef.current(false); if (index === 0) { @@ -225,6 +246,7 @@ export function useGates({ queue.current.push(entry); if (entry.kind === "permission") { setPermissionQueueDepth((depth) => depth + 1); + syncQueuedApprovals(); } setGatePendingRef.current(true); if (queue.current.length === 1) updateVisibleEntry(); @@ -235,6 +257,7 @@ export function useGates({ activeId.current = null; setActiveApproval(null); setPermissionQueueDepth(0); + setQueuedApprovals([]); for (const entry of remaining) { clearEntryTimer(entry); detachEntryAbort(entry); @@ -332,6 +355,7 @@ export function useGates({ return { activeApproval, permissionQueueDepth, + queuedApprovals, gateOpen: activeApproval !== null, approve: (id) => settlePlan(id, true), reject: (id) => settlePlan(id, false),