Skip to content

Commit 99c8780

Browse files
committed
Gate addressing fleet verbs with subtree authority
Nested interrupt/close/resume/followup now share send_input's assertCanTargetAgent check and fail closed without an actorId. Soft-interrupt wait_agents collects so a later followup cannot resurrect an already-observed interrupt as done.
1 parent fbd8665 commit 99c8780

7 files changed

Lines changed: 269 additions & 47 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
219219

220220
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:
221221

222-
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) plus reserved name (`send_input`) so a later mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
223-
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. `read_agent_trace` is a production call site. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` and `close_agent` terminalize the wait mailbox immediately.
222+
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
223+
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, and `followup_task`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
224224
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.
225225

226226
#### Closed director fleet (`src/agent/directors/`)

src/subagent/agent-fleet.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,35 @@ describe("interrupt_agent unblocks wait_agents", () => {
800800
expect(results[0]!.status).toBe("interrupted");
801801
expect(results[0]!.report).toContain("salvage");
802802
});
803+
804+
test("soft-interrupt wait collects so a later followup cannot resurrect done", async () => {
805+
const sessions = createSubAgentSessionStore();
806+
const fleetRecords = createFleetRecords();
807+
const worker = sessions.start({
808+
id: "soft-int",
809+
description: "looping",
810+
agentId: "explorer",
811+
brief: "b",
812+
retained: true,
813+
});
814+
sessions.markRunning(worker.id);
815+
// Running fleet record + soft-interrupted session (lifecycle only) —
816+
// the wait soft path must interrupt+take before returning.
817+
fleetRecords.register(worker.id);
818+
sessions.registerInterrupt(worker.id, () => {});
819+
sessions.interruptOne(worker.id);
820+
821+
const wait = createWaitAgentsTool({ sessions, fleetRecords });
822+
const waited = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 });
823+
expect(waited.timed_out).toBe(false);
824+
const results = waited.results as { status: string }[];
825+
expect(results[0]!.status).toBe("interrupted");
826+
expect(fleetRecords.peek(worker.id)?.collected).toBe(true);
827+
828+
fleetRecords.completeAfterInterrupt(worker.id, "resurrected reply");
829+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
830+
expect(fleetRecords.peek(worker.id)?.collected).toBe(true);
831+
});
803832
});
804833

805834
describe("close_agent unblocks wait_agents", () => {

src/subagent/agent-fleet.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,8 +721,9 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
721721
}
722722
const session = deps.sessions.get(id);
723723
if (isSoftInterrupted(session)) {
724-
// Terminalize + collect so an omitted-targets re-wait does not keep
725-
// seeing this id as uncollected / re-deliver soft-interrupt.
724+
// Match the mailbox to what we report (include salvage report when
725+
// present), then collect so a later completeAfterInterrupt cannot
726+
// resurrect this wait as "done".
726727
deps.fleetRecords.interrupt(id, session.report);
727728
const taken = deps.fleetRecords.take(id);
728729
return {

src/subagent/authority.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,15 @@ function isDescendant(
107107
}
108108

109109
/**
110-
* Live gate for `read_agent_trace` (and any future verb that addresses an
111-
* existing session). Callers that only spawn (`task`, `spawn_agent`) never
112-
* reach this check.
113-
*
114110
* Authority rule (root owns its tree; a child manages only its own
115111
* descendants): throws unless `actor` is Tier 1, or `targetId` is `actor.id`
116112
* itself, or a descendant of `actor.id` in `nodes`. A Tier 3 leaf holds no
117113
* fleet verbs at all and can never reach this check with a real call, so it
118114
* always fails closed here too.
115+
*
116+
* Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`,
117+
* `close_agent`, `resume_agent`, and `followup_task` (nested mounts pass
118+
* authority from run.ts; Tier-1 primary omits it and stays unrestricted).
119119
*/
120120
export function assertCanTargetAgent(
121121
actor: { readonly id: string; readonly tier: SubagentTier },

src/subagent/lifecycle-tools.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,4 +397,171 @@ describe("send_input", () => {
397397
);
398398
expect(denied.isError).toBe(true);
399399
});
400+
401+
test("fails closed when nested authority has no actorId", async () => {
402+
const sessions = createSubAgentSessionStore();
403+
const worker = sessions.start({
404+
id: "worker",
405+
description: "worker",
406+
agentId: "a",
407+
brief: "b",
408+
});
409+
sessions.markRunning(worker.id);
410+
sessions.registerDeliver(worker.id, () => {});
411+
const sendInput = createSendInputTool({
412+
sessions,
413+
authority: {
414+
actorId: undefined,
415+
tier: "nested-orchestrator",
416+
getNodes: () => sessions.list(),
417+
},
418+
});
419+
if (sendInput.kind !== "full") throw new Error("expected full tool");
420+
const denied = await sendInput.handler(
421+
{ id: "no-actor", name: "send_input", arguments: { target: worker.id, message: "x" } },
422+
new AbortController().signal,
423+
);
424+
expect(denied.isError).toBe(true);
425+
expect(String(denied.content)).toContain("no resolvable session");
426+
});
427+
});
428+
429+
describe("nested lifecycle authority", () => {
430+
function nestAuthority(sessions: ReturnType<typeof createSubAgentSessionStore>, actorId: string) {
431+
return {
432+
actorId,
433+
tier: "nested-orchestrator" as const,
434+
getNodes: () => sessions.list(),
435+
};
436+
}
437+
438+
test("interrupt_agent denies a sibling and allows a descendant", async () => {
439+
const sessions = createSubAgentSessionStore();
440+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
441+
const child = sessions.start({
442+
id: "child",
443+
description: "c",
444+
agentId: "a",
445+
brief: "b",
446+
parentSessionId: nested.id,
447+
});
448+
const sibling = sessions.start({ id: "sibling", description: "s", agentId: "a", brief: "b" });
449+
for (const s of [child, sibling]) {
450+
sessions.markRunning(s.id);
451+
sessions.registerInterrupt(s.id, () => {});
452+
}
453+
const interrupt = createInterruptAgentTool({
454+
sessions,
455+
fleetRecords: createFleetRecords(),
456+
authority: nestAuthority(sessions, nested.id),
457+
});
458+
expect((await callTool(interrupt, { target: child.id })).status).toBe("interrupted");
459+
if (interrupt.kind !== "full") throw new Error("expected full tool");
460+
const denied = await interrupt.handler(
461+
{ id: "d", name: "interrupt_agent", arguments: { target: sibling.id } },
462+
new AbortController().signal,
463+
);
464+
expect(denied.isError).toBe(true);
465+
});
466+
467+
test("close_agent denies a sibling and allows a descendant", async () => {
468+
const sessions = createSubAgentSessionStore();
469+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
470+
const child = sessions.start({
471+
id: "child",
472+
description: "c",
473+
agentId: "a",
474+
brief: "b",
475+
parentSessionId: nested.id,
476+
});
477+
const sibling = sessions.start({ id: "sibling", description: "s", agentId: "a", brief: "b" });
478+
for (const s of [child, sibling]) sessions.registerClose(s.id, async () => {});
479+
const close = createCloseAgentTool({
480+
sessions,
481+
fleetRecords: createFleetRecords(),
482+
authority: nestAuthority(sessions, nested.id),
483+
});
484+
expect((await callTool(close, { target: child.id })).status).toBe("shutdown");
485+
if (close.kind !== "full") throw new Error("expected full tool");
486+
const denied = await close.handler(
487+
{ id: "d", name: "close_agent", arguments: { target: sibling.id } },
488+
new AbortController().signal,
489+
);
490+
expect(denied.isError).toBe(true);
491+
expect(sessions.get(sibling.id)?.lifecycleStatus).not.toBe("shutdown");
492+
});
493+
494+
test("followup_task denies a sibling and allows a descendant", async () => {
495+
const sessions = createSubAgentSessionStore();
496+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
497+
const child = sessions.start({
498+
id: "child",
499+
description: "c",
500+
agentId: "a",
501+
brief: "b",
502+
parentSessionId: nested.id,
503+
retained: true,
504+
});
505+
const sibling = sessions.start({
506+
id: "sibling",
507+
description: "s",
508+
agentId: "a",
509+
brief: "b",
510+
retained: true,
511+
});
512+
for (const s of [child, sibling]) {
513+
sessions.complete(s.id, "done");
514+
sessions.registerFollowup(s.id, async () => "reply");
515+
}
516+
const followup = createFollowupTaskTool({
517+
sessions,
518+
authority: nestAuthority(sessions, nested.id),
519+
});
520+
expect((await callTool(followup, { target: child.id, message: "more" })).status).toBe(
521+
"completed",
522+
);
523+
if (followup.kind !== "full") throw new Error("expected full tool");
524+
const denied = await followup.handler(
525+
{
526+
id: "d",
527+
name: "followup_task",
528+
arguments: { target: sibling.id, message: "more" },
529+
},
530+
new AbortController().signal,
531+
);
532+
expect(denied.isError).toBe(true);
533+
});
534+
535+
test("resume_agent denies a sibling and allows a descendant", async () => {
536+
const sessions = createSubAgentSessionStore();
537+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
538+
const child = sessions.start({
539+
id: "child",
540+
description: "c",
541+
agentId: "a",
542+
brief: "b",
543+
parentSessionId: nested.id,
544+
retained: true,
545+
});
546+
const sibling = sessions.start({
547+
id: "sibling",
548+
description: "s",
549+
agentId: "a",
550+
brief: "b",
551+
retained: true,
552+
});
553+
sessions.complete(child.id, "done");
554+
sessions.complete(sibling.id, "done");
555+
const resume = createResumeAgentTool({
556+
sessions,
557+
authority: nestAuthority(sessions, nested.id),
558+
});
559+
expect((await callTool(resume, { target: child.id })).status).toBe("running");
560+
if (resume.kind !== "full") throw new Error("expected full tool");
561+
const denied = await resume.handler(
562+
{ id: "d", name: "resume_agent", arguments: { target: sibling.id } },
563+
new AbortController().signal,
564+
);
565+
expect(denied.isError).toBe(true);
566+
});
400567
});

src/subagent/lifecycle-tools.ts

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,22 @@ function descendantsClosingOrder(
9696
return order;
9797
}
9898

99+
/**
100+
* Nested-orchestrator subtree gate for addressing verbs. When `authority` is
101+
* omitted (Tier-1 primary mount), targeting is unrestricted. When present,
102+
* a missing `actorId` fails closed — same rule as read_agent_trace.
103+
*/
104+
export interface LifecycleAuthority {
105+
actorId: string | undefined;
106+
tier: SubagentTier;
107+
getNodes: () => readonly FleetNode[];
108+
}
109+
99110
export interface LifecycleToolDeps {
100111
sessions: SubAgentSessionStore;
101112
/** Optional for resume/followup/send_input; close and interrupt require it (see CloseAgentToolDeps / InterruptAgentToolDeps). */
102113
fleetRecords?: FleetRecordsHandle;
114+
authority?: LifecycleAuthority;
103115
}
104116

105117
/** close_agent always terminalizes the wait mailbox — no silent skip. */
@@ -112,14 +124,33 @@ export type InterruptAgentToolDeps = LifecycleToolDeps & {
112124
fleetRecords: FleetRecordsHandle;
113125
};
114126

115-
export interface SendInputAuthority {
116-
actorId: string | undefined;
117-
tier: SubagentTier;
118-
getNodes: () => readonly FleetNode[];
119-
}
120-
121-
export interface SendInputToolDeps extends LifecycleToolDeps {
122-
authority?: SendInputAuthority;
127+
function gateTarget(
128+
deps: LifecycleToolDeps,
129+
toolName: string,
130+
target: string,
131+
callId: string,
132+
): ToolResult | undefined {
133+
if (deps.authority === undefined) return undefined;
134+
if (deps.authority.actorId === undefined) {
135+
return lifecycleResult(
136+
callId,
137+
`Error: ${toolName} is unavailable for this worker (no resolvable session ` +
138+
"id to scope descendant access).",
139+
);
140+
}
141+
try {
142+
assertCanTargetAgent(
143+
{ id: deps.authority.actorId, tier: deps.authority.tier },
144+
target,
145+
deps.authority.getNodes(),
146+
);
147+
} catch (cause) {
148+
if (cause instanceof FleetAuthorityError) {
149+
return lifecycleResult(callId, `Error: ${cause.message}`);
150+
}
151+
throw cause;
152+
}
153+
return undefined;
123154
}
124155

125156
export function createCloseAgentTool(deps: CloseAgentToolDeps): AgentTool {
@@ -131,6 +162,8 @@ export function createCloseAgentTool(deps: CloseAgentToolDeps): AgentTool {
131162
return lifecycleResult(call.id, `Error: close_agent arguments invalid: ${parsed.summary}`);
132163
}
133164
const target = parsed.target.trim();
165+
const denied = gateTarget(deps, "close_agent", target, call.id);
166+
if (denied !== undefined) return denied;
134167
if (deps.sessions.get(target) === undefined) {
135168
return lifecycleResult(
136169
call.id,
@@ -173,6 +206,8 @@ export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool {
173206
return lifecycleResult(call.id, `Error: resume_agent arguments invalid: ${parsed.summary}`);
174207
}
175208
const target = parsed.target.trim();
209+
const denied = gateTarget(deps, "resume_agent", target, call.id);
210+
if (denied !== undefined) return denied;
176211
const outcome = deps.sessions.resumeOne(target);
177212
if (!outcome.ok) {
178213
const hint = outcome.hint !== undefined ? ` ${outcome.hint}` : "";
@@ -221,6 +256,8 @@ export function createInterruptAgentTool(deps: InterruptAgentToolDeps): AgentToo
221256
);
222257
}
223258
const target = parsed.target.trim();
259+
const denied = gateTarget(deps, "interrupt_agent", target, call.id);
260+
if (denied !== undefined) return denied;
224261
const outcome = deps.sessions.interruptOne(target);
225262
if (!outcome.ok) {
226263
return lifecycleResult(
@@ -273,6 +310,8 @@ export function createFollowupTaskTool(deps: LifecycleToolDeps): AgentTool {
273310
);
274311
}
275312
const target = parsed.target.trim();
313+
const denied = gateTarget(deps, "followup_task", target, call.id);
314+
if (denied !== undefined) return denied;
276315
const message = parsed.message.trim();
277316
if (message.length === 0) {
278317
return lifecycleResult(call.id, "Error: followup_task requires a non-empty message.");
@@ -327,7 +366,7 @@ export const sendInputToolDefinition: ToolDefinition = {
327366
},
328367
};
329368

330-
export function createSendInputTool(deps: SendInputToolDeps): AgentTool {
369+
export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
331370
return tool({
332371
definition: sendInputToolDefinition,
333372
handler: async (call, _signal): Promise<ToolResult> => {
@@ -336,6 +375,8 @@ export function createSendInputTool(deps: SendInputToolDeps): AgentTool {
336375
return lifecycleResult(call.id, `Error: send_input arguments invalid: ${parsed.summary}`);
337376
}
338377
const target = parsed.target.trim();
378+
const denied = gateTarget(deps, "send_input", target, call.id);
379+
if (denied !== undefined) return denied;
339380
const message = parsed.message.trim();
340381
if (message.length === 0) {
341382
return lifecycleResult(call.id, "Error: send_input requires a non-empty message.");
@@ -347,27 +388,6 @@ export function createSendInputTool(deps: SendInputToolDeps): AgentTool {
347388
`(got ${message.length}).`,
348389
);
349390
}
350-
if (deps.authority !== undefined) {
351-
if (deps.authority.actorId === undefined) {
352-
return lifecycleResult(
353-
call.id,
354-
"Error: send_input is unavailable for this worker (no resolvable session " +
355-
"id to scope descendant access).",
356-
);
357-
}
358-
try {
359-
assertCanTargetAgent(
360-
{ id: deps.authority.actorId, tier: deps.authority.tier },
361-
target,
362-
deps.authority.getNodes(),
363-
);
364-
} catch (cause) {
365-
if (cause instanceof FleetAuthorityError) {
366-
return lifecycleResult(call.id, `Error: ${cause.message}`);
367-
}
368-
throw cause;
369-
}
370-
}
371391
const interrupt = parsed.interrupt === true;
372392
const outcome = deps.sessions.sendInputOne(target, message, {
373393
...(interrupt ? { interrupt: true } : {}),

0 commit comments

Comments
 (0)