Skip to content

Commit 5b954db

Browse files
committed
Fail closed on unresolvable fleet tier at the mount point
The tier gate in runSubAgent only checked closed-director dispatches: a project-local or plugin AgentProfile with orchestrator: true has no directorId, so the gate was skipped and both task and search_agents mounted unconditionally. That is the exact "advisory, not binding" failure mode this epic exists to remove. task-tool.ts now resolves an explicit tier for every dispatch (closed DirectorPackage.tier, or a profile's opt-in fleetTier field — never "tier", which collides with the existing model-speed tier some profiles already set) and forwards it as orchestratorTier. runSubAgent treats a missing orchestratorTier as "leaf" and denies task/search_agents instead of skipping the check. Added: a gate-level test driving runSubAgent itself (not just the assert functions) to prove an unresolvable tier and an explicit leaf tier are both denied, and that a resolved non-leaf tier passes the gate; a registry-wide test pinning tier against spawn.maySpawn for all 16 directors so the two hand-maintained fields cannot silently drift.
1 parent 03185d6 commit 5b954db

9 files changed

Lines changed: 169 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1919
Every director package carries a required `tier` (`orchestrator` /
2020
`nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard
2121
(and any package with `spawn.maySpawn`) is scoped to its own subtree, and every
22-
other director gets no fleet verbs at all. The check lives in code
23-
(`src/subagent/authority.ts`, wired into `runSubAgent`'s tool-mount point) so a
24-
leaf cannot obtain a fleet verb and a nested orchestrator cannot reach a
25-
sibling or ancestor — this is the foundation the next fleet-control verbs land
26-
against. `task()` is unchanged and still the only spawn verb.
22+
other director gets no fleet verbs at all. The gate lives in code
23+
(`src/subagent/authority.ts`, wired into `runSubAgent`'s tool-mount point) and
24+
fails closed: a caller whose tier cannot be resolved — including a
25+
project-local or plugin agent profile with `orchestrator: true` that has not
26+
explicitly opted in via `fleetTier: "nested-orchestrator"` — is denied
27+
`task`/`search_agents` rather than silently trusted. This is the foundation
28+
the next fleet-control verbs (spawn/list/steer a live agent) land against;
29+
the subtree-scoping rule for those is written and tested but not yet wired to
30+
a live call site. `task()` is unchanged and still the only spawn verb.
2731

2832
## [0.2.107] - 2026-08-24
2933

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
239239

240240
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:
241241

242-
- **Mount-time gate — live today.** `runSubAgent` (`src/subagent/run.ts`) resolves the spawned director's tier via `tierForDirectorId` (`src/agent/directors/registry.ts`) and calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing `task` / `search_agents`. A Tier 3 leaf can never receive a fleet verb even if `orchestrator: true` is passed by mistake — the mount throws instead of silently installing the tool. `FLEET_VERBS` in `authority.ts` also names the not-yet-implemented verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `followup_task`) so their future mount sites inherit the same gate.
242+
- **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`, or an explicit non-leaf `AgentProfile.fleetTier` opt-in for a profile-sourced orchestrator — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing `task` / `search_agents`, 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 — it must also declare `fleetTier: "nested-orchestrator"`, or the mount throws `FleetAuthorityError`. (`AgentProfile.fleetTier` is deliberately not named `tier` — that name is already used, ad hoc, by some profiles for an unrelated model-speed selector; reusing it would have silently broken schema validation for those profiles, which is exactly what happened during review and was caught by the full test suite, not by inspection.) `FLEET_VERBS` in `authority.ts` also names the not-yet-implemented verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `followup_task`) so their future mount sites inherit the same gate.
243243
- **Subtree authority — a seam, not yet wired.** `assertCanTargetAgent(actor, targetId, nodes)` (`src/subagent/authority.ts`) implements the "root owns its tree; a child manages only its own descendants" rule (Tier 1 may target anyone, Tier 2 may target only its own descendants over the same `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks, Tier 3 always fails closed) — but **it has no production call site yet**. No verb today lets one live agent address another (`task` only spawns), so this rule is exercised only by `authority.test.ts` and is not enforced at runtime in this PR. It exists so CL-6942 (split spawn from wait) and CL-6944 (`send_input` steering) — the first verbs that make an agent addressable by another — can call it from day one instead of each inventing its own check. Treat it as unenforced until one of those wires a call site.
244244
- `task()` is unaffected and remains the only spawn verb until the new verbs land beside it (deprecated-not-deleted per the CL-6940 epic). Its argument schema and wire contract are unchanged; the tier check only gates which packages may have it mounted at all.
245245

src/agent/directors/registry.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
listDirectors,
1010
packageToProfile,
1111
resolveDirector,
12+
tierForDirectorId,
1213
} from "./registry.js";
1314

1415
describe("director registry", () => {
@@ -170,6 +171,20 @@ describe("director registry", () => {
170171
expect(s.spawn.allowlist).toHaveLength(15);
171172
});
172173

174+
// CL-6941: tier and spawn.maySpawn independently encode "may this package
175+
// spawn", hand-set across 16 files. This pins their agreement so drift
176+
// (adding maySpawn: true without bumping tier, or vice versa) fails a test
177+
// instead of surfacing as an unexplained FleetAuthorityError at dispatch.
178+
test("tier agrees with spawn.maySpawn for every director", () => {
179+
for (const id of DIRECTOR_IDS) {
180+
const pkg = DIRECTOR_REGISTRY[id];
181+
expect(pkg.tier !== "leaf").toBe(pkg.spawn.maySpawn);
182+
expect(tierForDirectorId(id)).toBe(pkg.tier);
183+
}
184+
expect(DIRECTOR_REGISTRY.skywalker.tier).toBe("orchestrator");
185+
expect(DIRECTOR_REGISTRY.greybeard.tier).toBe("nested-orchestrator");
186+
});
187+
173188
test("every director profile declares matching agent id in system prompt", () => {
174189
for (const id of DIRECTOR_IDS) {
175190
const profile = packageToProfile(DIRECTOR_REGISTRY[id]);

src/agent/profile-types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ export interface AgentProfile {
6868
// coordinators (e.g. a planning agent that fans work out to specialists);
6969
// leaf-task agents should leave this unset.
7070
orchestrator?: boolean;
71+
// Fleet authority tier (CL-6941) for a profile-sourced orchestrator. Only
72+
// meaningful alongside orchestrator: true. Named `fleetTier`, not `tier` —
73+
// `tier` is already an established profile field for model speed selection
74+
// ("fast" | "standard" | "clever", resolved via task(tier=...)); reusing
75+
// the name silently broke schema validation for profiles that set it. A
76+
// profile is outside the closed director set, so it is NOT trusted with
77+
// fleet verbs by default even when orchestrator: true is set — this must
78+
// be declared explicitly as "nested-orchestrator" to opt in.
79+
// Runtime-enforced at the tool-mount point (src/subagent/run.ts /
80+
// src/subagent/authority.ts): an orchestrator=true profile with no
81+
// fleetTier (or fleetTier: "leaf") is denied task/search_agents, fail-closed.
82+
fleetTier?: "orchestrator" | "nested-orchestrator" | "leaf";
7183
// Optional inference-turn budget when this profile is dispatched via task(agent=...).
7284
// Floor-sanitized (≥1) at dispatch time; task(maxTurns) overrides when set.
7385
maxTurns?: number;

src/agent/profiles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const AgentProfileSchema = type({
5151
"systemPromptRole?": "string",
5252
"systemPromptPath?": "string",
5353
"orchestrator?": "boolean",
54+
"fleetTier?": "'orchestrator' | 'nested-orchestrator' | 'leaf'",
5455
"maxTurns?": "number",
5556
});
5657

src/subagent/run-authority.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Gate-level proof for CL-6941: authority.test.ts proves the assert
3+
* functions throw when called directly, which is necessary but not
4+
* sufficient — it does not prove runSubAgent itself cannot be talked into
5+
* mounting a fleet verb for a caller whose tier cannot be established. These
6+
* tests drive runSubAgent (the real mount point) end to end.
7+
*/
8+
9+
import { describe, expect, test } from "bun:test";
10+
import { tmpdir } from "node:os";
11+
import { mkdtemp } from "node:fs/promises";
12+
import { join } from "node:path";
13+
14+
import { createPermissionGate } from "../permission/gate.js";
15+
import { FleetAuthorityError } from "./authority.js";
16+
import { runSubAgent } from "./run.js";
17+
import type { RunSubAgentParams } from "./types.js";
18+
19+
const testPermissionGate = createPermissionGate({
20+
approvals: [],
21+
interactive: false,
22+
skipPermissions: true,
23+
});
24+
25+
async function tmpCwd(): Promise<string> {
26+
return mkdtemp(join(tmpdir(), "cl6941-run-authority-"));
27+
}
28+
29+
function baseParams(cwd: string, workdirBase: string): Omit<RunSubAgentParams, "orchestrator"> {
30+
return {
31+
cwd,
32+
workdirBase,
33+
permissionGate: testPermissionGate,
34+
provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" },
35+
description: "gate probe",
36+
prompt: "no-op",
37+
};
38+
}
39+
40+
describe("runSubAgent fleet-verb mount gate (CL-6941, fails closed)", () => {
41+
test("orchestrator=true with no resolvable tier (non-closed-director profile shape) is denied", async () => {
42+
const cwd = await tmpCwd();
43+
await expect(
44+
runSubAgent({
45+
...baseParams(cwd, join(cwd, ".ctx")),
46+
orchestrator: true,
47+
// No directorId, no orchestratorTier — this is exactly the shape a
48+
// project/plugin AgentProfile with orchestrator: true produces.
49+
// nestedDispatch is deliberately omitted: the tier gate must reject
50+
// before that later "requires nestedDispatch" check is even reached.
51+
}),
52+
).rejects.toBeInstanceOf(FleetAuthorityError);
53+
});
54+
55+
test("orchestrator=true with an explicit leaf tier is denied", async () => {
56+
const cwd = await tmpCwd();
57+
await expect(
58+
runSubAgent({
59+
...baseParams(cwd, join(cwd, ".ctx")),
60+
orchestrator: true,
61+
orchestratorTier: "leaf",
62+
}),
63+
).rejects.toBeInstanceOf(FleetAuthorityError);
64+
});
65+
66+
test("orchestrator=true with a resolved non-leaf tier passes the gate (fails later, not on authority)", async () => {
67+
const cwd = await tmpCwd();
68+
try {
69+
await runSubAgent({
70+
...baseParams(cwd, join(cwd, ".ctx")),
71+
orchestrator: true,
72+
orchestratorTier: "nested-orchestrator",
73+
// Deliberately still omit nestedDispatch: a tier that passes the gate
74+
// must reach the *next* check (nestedDispatch required) instead of
75+
// being denied by assertTierMayMountFleetVerb.
76+
});
77+
throw new Error("expected runSubAgent to reject (missing nestedDispatch)");
78+
} catch (err) {
79+
expect(err).not.toBeInstanceOf(FleetAuthorityError);
80+
expect(String((err as Error).message)).toContain("nestedDispatch");
81+
}
82+
});
83+
});

src/subagent/run.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ import {
105105
} from "./stop-policy.js";
106106
import { SubAgentDirector } from "./nudge-director.js";
107107
import { assertTierMayMountFleetVerb } from "./authority.js";
108-
import { tierForDirectorId } from "../agent/directors/registry.js";
109108
import {
110109
abortError,
111110
createSubAgentSpawnRegistryPlugin,
@@ -427,17 +426,17 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
427426
// the prompt. Nested dispatch always forbids further orchestration so the
428427
// tree bottoms out after one hop.
429428
if (params.orchestrator === true) {
430-
// Tier enforcement at the mount point (CL-6941), not the prompt: a
431-
// closed director resolved to Tier 3 must never reach here holding
432-
// orchestrator=true — fail closed instead of silently installing fleet
433-
// verbs on a leaf. Non-director profiles (params.directorId unset)
434-
// are outside the closed-director tier system and are not checked here.
435-
const tier =
436-
params.directorId !== undefined ? tierForDirectorId(params.directorId) : undefined;
437-
if (tier !== undefined) {
438-
for (const verb of ["task", "search_agents"]) {
439-
assertTierMayMountFleetVerb(tier, verb);
440-
}
429+
// Tier enforcement at the mount point (CL-6941), not the prompt: FAILS
430+
// CLOSED. The caller (task-tool.ts) resolves orchestratorTier from
431+
// either the closed DirectorPackage.tier or an explicit
432+
// AgentProfile.tier opt-in; an unresolved tier defaults to "leaf" here,
433+
// not to "skip the check" — a caller that cannot be identified must be
434+
// denied, never silently trusted. This is what stops a project/plugin
435+
// AgentProfile with orchestrator: true from mounting task/search_agents
436+
// just because it is outside the closed director set.
437+
const tier = params.orchestratorTier ?? "leaf";
438+
for (const verb of ["task", "search_agents"]) {
439+
assertTierMayMountFleetVerb(tier, verb);
441440
}
442441
if (params.nestedDispatch === undefined) {
443442
throw new Error(

src/subagent/task-tool.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
defaultEffortForDirector,
2121
formatDirectorSystemPrompt,
2222
} from "../agent/directors/identity.js";
23-
import type { DirectorPackage } from "../agent/directors/types.js";
23+
import type { DirectorPackage, SubagentTier } from "../agent/directors/types.js";
2424
import type { Settings } from "../config/settings.js";
2525
import {
2626
resolveSubAgentMaxTurns,
@@ -348,6 +348,14 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
348348
let capabilities: CapabilityFilter | undefined;
349349
let systemPromptRole: string | undefined;
350350
let orchestrator = false;
351+
/**
352+
* Fleet authority tier (CL-6941) for this dispatch — forwarded to
353+
* runSubAgent, which fails closed (denies task/search_agents) when
354+
* orchestrator is true and this is left undefined or resolves to
355+
* "leaf". Set alongside `orchestrator = true` in every branch below;
356+
* never left to default once orchestrator is true.
357+
*/
358+
let orchestratorTier: SubagentTier | undefined;
351359
let profileMaxTurns: number | undefined;
352360
let resolvedDirectorId: string | undefined;
353361
let resolvedPackage: DirectorPackage | undefined;
@@ -423,6 +431,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
423431
if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns;
424432
if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) {
425433
orchestrator = true;
434+
orchestratorTier = pkg.tier;
426435
if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) {
427436
nestedSpawnAllowlist = pkg.spawn.allowlist;
428437
}
@@ -476,6 +485,15 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
476485
// even if their profile is marked orchestrator — recursion bottoms out.
477486
if (profile.orchestrator === true && deps.allowOrchestrator !== false) {
478487
orchestrator = true;
488+
// Fail closed (CL-6941): a profile is outside the closed director
489+
// set, so orchestrator: true alone does not grant a tier. Only an
490+
// explicit non-leaf profile.fleetTier opts in; anything else
491+
// (absent, or "leaf") leaves orchestratorTier undefined, which
492+
// runSubAgent treats as "leaf" and denies task/search_agents.
493+
orchestratorTier =
494+
profile.fleetTier !== undefined && profile.fleetTier !== "leaf"
495+
? profile.fleetTier
496+
: undefined;
479497
}
480498
// Per-agent pinned inference (provider/model/effort), if declared.
481499
// Resolution uses policy (mode: pin / agentModelFallback: none) so a
@@ -510,6 +528,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
510528
if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns;
511529
if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) {
512530
orchestrator = true;
531+
orchestratorTier = pkg.tier;
513532
if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) {
514533
nestedSpawnAllowlist = pkg.spawn.allowlist;
515534
}
@@ -802,7 +821,13 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
802821
...(capabilities !== undefined ? { capabilities } : {}),
803822
...(systemPromptRole !== undefined ? { systemPromptRole } : {}),
804823
...(resolvedDirectorId !== undefined ? { directorId: resolvedDirectorId } : {}),
805-
...(orchestrator ? { orchestrator: true, nestedDispatch: nestedDispatch! } : {}),
824+
...(orchestrator
825+
? {
826+
orchestrator: true,
827+
...(orchestratorTier !== undefined ? { orchestratorTier } : {}),
828+
nestedDispatch: nestedDispatch!,
829+
}
830+
: {}),
806831
maxTurns: resolvedMaxTurns,
807832
...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}),
808833
};

src/subagent/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { PermissionGate } from "../permission/gate.js";
1616
import type { ReasoningEffort } from "../provider/reasoning-effort.js";
1717
import type { SubAgentSessionStore } from "./session-store.js";
1818
import type { TaskIntent } from "./report.js";
19+
import type { SubagentTier } from "../agent/directors/types.js";
1920

2021
export interface SubAgentProvider {
2122
providerName: string;
@@ -106,6 +107,15 @@ export type RunSubAgentParams = {
106107
// Requires nestedDispatch so the task tool can actually be installed —
107108
// advertising permission without the tool is a hard break.
108109
orchestrator?: boolean;
110+
/**
111+
* Fleet authority tier (CL-6941) for this dispatch, resolved by the caller
112+
* (task-tool.ts) from either the closed DirectorPackage.tier or an explicit
113+
* AgentProfile.tier opt-in. Required whenever orchestrator is true:
114+
* runSubAgent fails closed (denies task/search_agents) when orchestrator is
115+
* true and this is undefined or "leaf" — an unrecognized or unresolved tier
116+
* must never mount a fleet verb. See src/subagent/authority.ts.
117+
*/
118+
orchestratorTier?: SubagentTier;
109119
// Present only when orchestrator is true. Installs task + search_agents so
110120
// the orchestrator can actually dispatch workers.
111121
nestedDispatch?: NestedDispatchDeps;

0 commit comments

Comments
 (0)