Skip to content
Merged
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
18 changes: 14 additions & 4 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
95 changes: 95 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
4 changes: 4 additions & 0 deletions src/permission/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/subagent/identity-context.ts
Original file line number Diff line number Diff line change
@@ -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<SubAgentIdentity>();

export function runWithSubAgentIdentity<T>(
identity: SubAgentIdentity,
fn: () => Promise<T>,
): Promise<T> {
return subAgentIdentityAls.run(identity, fn);
}

export function getSubAgentIdentity(): SubAgentIdentity | undefined {
return subAgentIdentityAls.getStore();
}
13 changes: 12 additions & 1 deletion src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -415,11 +416,21 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise<string> {
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());
Expand Down
1 change: 1 addition & 0 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,7 @@ export function App({
onReject={gates.reject}
onSelectOperator={gates.selectOperator}
permissionQueueDepth={gates.permissionQueueDepth}
queuedApprovals={gates.queuedApprovals}
onResolvePermission={gates.resolvePermission}

width={columns}
Expand Down
5 changes: 4 additions & 1 deletion src/tui/components/modal-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;


Expand Down Expand Up @@ -116,6 +117,7 @@ export function ModalStack({
onReject,
onSelectOperator,
permissionQueueDepth,
queuedApprovals,
onResolvePermission,
width,
}: ModalStackProps): ReactNode {
Expand Down Expand Up @@ -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 } : {})}
Expand Down
46 changes: 46 additions & 0 deletions src/tui/components/permission-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -187,11 +202,16 @@ function descriptorArgs(request: PermissionRequest): Record<string, unknown> {
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("");
Expand Down Expand Up @@ -286,6 +306,14 @@ export function PermissionModal({
width={Math.max(24, width - 2)}
>
<Text bold color={toolColor}>Approval needed</Text>
{request.agentLabel !== undefined && (
<Text>
<Text color={agentTagColor(request.agentLabel)} bold>{`⏺ ${request.agentLabel}`}</Text>
{request.cwd !== undefined && (
<Text color={color("muted")}>{` ${request.cwd}`}</Text>
)}
</Text>
)}
{goalTimeoutSecs !== null && (
<Text color={color("muted")}>
{`Goal mode · auto-skip in ~${goalTimeoutSecs}s if no response`}
Expand All @@ -305,6 +333,24 @@ export function PermissionModal({
? ` · +${queuedBehind} more approval${queuedBehind === 1 ? "" : "s"} queued`
: ""}
</Text>
{shownQueued.length > 0 && (
<Box marginLeft={2} flexDirection="column">
{shownQueued.map((entry) => (
<Text key={entry.id} color={color("muted")}>
{"· "}
{entry.agentLabel !== undefined ? (
<Text color={agentTagColor(entry.agentLabel)}>{entry.agentLabel}</Text>
) : (
<Text color={color("muted")}>session</Text>
)}
{` — ${entry.tool}`}
</Text>
))}
{hiddenQueuedCount > 0 && (
<Text color={color("muted")}>{`… ${hiddenQueuedCount} more waiting`}</Text>
)}
</Box>
)}
{descriptor.isShell && (
// The exact string that will execute, always shown verbatim: the
// segment list below is a lossy reconstruction, and the scope hints
Expand Down
Loading
Loading