From eeda728e9822841fc1fc5b08dae64a983aec72e0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 21:31:15 -0700 Subject: [PATCH 1/3] Add fleet authority tiers to the subagents package Every director package now carries a required tier (orchestrator / nested-orchestrator / leaf), enforced in code at runSubAgent's existing tool-mount point via src/subagent/authority.ts, not by prompt wording. A Tier 3 leaf can never mount a fleet verb, and a Tier 2 nested orchestrator can only target its own descendants. task() is unchanged. --- CHANGELOG.md | 12 ++ docs/ARCHITECTURE.md | 18 ++- src/agent/directors/brand-reviewer/package.ts | 1 + src/agent/directors/bruckheimer/package.ts | 1 + src/agent/directors/build/package.ts | 1 + src/agent/directors/critique/package.ts | 1 + src/agent/directors/draper/package.ts | 1 + src/agent/directors/emil/package.ts | 1 + src/agent/directors/explore/package.ts | 1 + src/agent/directors/gaasbot/package.ts | 1 + src/agent/directors/greybeard/package.ts | 1 + src/agent/directors/intern/package.ts | 1 + src/agent/directors/neckbeard/package.ts | 1 + src/agent/directors/plan/package.ts | 1 + src/agent/directors/registry.ts | 6 + src/agent/directors/shakespeare/package.ts | 1 + src/agent/directors/skywalker/package.ts | 1 + src/agent/directors/tester/package.ts | 1 + src/agent/directors/testsmith/package.ts | 1 + src/agent/directors/types.ts | 14 +++ src/subagent/authority.test.ts | 78 ++++++++++++ src/subagent/authority.ts | 115 ++++++++++++++++++ src/subagent/run.ts | 14 +++ 23 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 src/subagent/authority.test.ts create mode 100644 src/subagent/authority.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb03c0b..a80e4c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Agent + +- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.** + Every director package carries a required `tier` (`orchestrator` / + `nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard + (and any package with `spawn.maySpawn`) is scoped to its own subtree, and every + other director gets no fleet verbs at all. The check lives in code + (`src/subagent/authority.ts`, wired into `runSubAgent`'s tool-mount point) so a + leaf cannot obtain a fleet verb and a nested orchestrator cannot reach a + sibling or ancestor — this is the foundation the next fleet-control verbs land + against. `task()` is unchanged and still the only spawn verb. + ## [0.2.107] - 2026-08-24 ### Agent diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48ec68e9..ea3cb3e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -227,9 +227,25 @@ When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` + `search_agents` with `allowOrchestrator: false` so the tree bottoms out. Unknown `agent` ids fail closed. +#### Fleet authority tiers (`src/subagent/authority.ts`) (CL-6941) + +Every director package carries a required `tier: SubagentTier` field (`src/agent/directors/types.ts`) — data on the package, never a prompt instruction: + +| Tier | Who | Fleet surface | +| ------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| 1 — `orchestrator` | skywalker (primary) | Full fleet control over the whole tree. | +| 2 — `nested-orchestrator` | greybeard, or any package with `spawn.maySpawn` | Same fleet surface, scoped to its own subtree: may manage only its own descendants, never a sibling or ancestor. | +| 3 — `leaf` | every other director | No fleet verbs at all. | + +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: + +- **Mount-time gate.** `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. +- **Subtree authority.** `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 (walked over the same `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks — no parallel tree), Tier 3 holds no fleet verbs and always fails closed. This is the authority primitive the fleet-targeting verbs land against in later child issues (CL-6942, CL-6951, CL-6945, CL-6946); CL-6941 lands the boundary and its tests, not the verbs. +- `task()` is unaffected and remains the only spawn verb until the new verbs land beside it (deprecated-not-deleted per the CL-6940 epic). + #### Closed director fleet (`src/agent/directors/`) -Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`) registered in a **closed** set of 16 ids. There is no catch-all worker: `task` without `agent` or non-general `intent`, and `task(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `createTaskTool` (not prompt-only). Skywalker is the primary session identity: `task(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog. +Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`, fleet authority `tier`) registered in a **closed** set of 16 ids. There is no catch-all worker: `task` without `agent` or non-general `intent`, and `task(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `createTaskTool` (not prompt-only). Skywalker is the primary session identity: `task(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog. **Primary** diff --git a/src/agent/directors/brand-reviewer/package.ts b/src/agent/directors/brand-reviewer/package.ts index dd5d8c3f..b048893a 100644 --- a/src/agent/directors/brand-reviewer/package.ts +++ b/src/agent/directors/brand-reviewer/package.ts @@ -16,6 +16,7 @@ export const brandReviewerPackage: DirectorPackage = { description: "DESIGN.md brand gate", tools: { allow: DOCS_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "docs", systemPrompt: `You are BrandReviewerDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts index f5519b2f..eab9b8b1 100644 --- a/src/agent/directors/bruckheimer/package.ts +++ b/src/agent/directors/bruckheimer/package.ts @@ -17,6 +17,7 @@ export const bruckheimerPackage: DirectorPackage = { description: "Product discovery specialist — user/product shape docs, not code", tools: { allow: DOCS_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "docs", systemPrompt: `You are BruckheimerDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/build/package.ts b/src/agent/directors/build/package.ts index fb77a012..faeb79d5 100644 --- a/src/agent/directors/build/package.ts +++ b/src/agent/directors/build/package.ts @@ -15,6 +15,7 @@ export const buildDirectorPackage: DirectorPackage = { optionalSkills: ["style", "philosophy", "typescript"], tools: { allow: BUILD_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 60 }, modelRole: "implement", systemPrompt: `You are BuildDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 58e89861..7524591a 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -19,6 +19,7 @@ export const critiquePackage: DirectorPackage = { optionalSkills: ["style", "philosophy"], tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 45 }, modelRole: "review", systemPrompt: `You are CritiqueDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/draper/package.ts b/src/agent/directors/draper/package.ts index fd1e5f6a..9581a133 100644 --- a/src/agent/directors/draper/package.ts +++ b/src/agent/directors/draper/package.ts @@ -18,6 +18,7 @@ export const draperPackage: DirectorPackage = { // Read-only critique — product write tools not mounted. tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "review", systemPrompt: `You are DraperDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/emil/package.ts b/src/agent/directors/emil/package.ts index b080a173..d114e686 100644 --- a/src/agent/directors/emil/package.ts +++ b/src/agent/directors/emil/package.ts @@ -18,6 +18,7 @@ export const emilPackage: DirectorPackage = { // Critique only — write tools not mounted. tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "review", systemPrompt: `You are EmilDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts index 5b7c1bf2..86a9fc80 100644 --- a/src/agent/directors/explore/package.ts +++ b/src/agent/directors/explore/package.ts @@ -24,6 +24,7 @@ FINDINGS SHAPE: Findings must be a scannable map — key paths, symbols, call fl OUT OF LANE → report Blockers naming the right director: build, plan, critique, greybeard, intern.`, tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 35 }, modelRole: "explore", }; diff --git a/src/agent/directors/gaasbot/package.ts b/src/agent/directors/gaasbot/package.ts index 1e89e43b..259f599d 100644 --- a/src/agent/directors/gaasbot/package.ts +++ b/src/agent/directors/gaasbot/package.ts @@ -19,6 +19,7 @@ export const gaasbotPackage: DirectorPackage = { optionalSkills: ["philosophy"], tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 35 }, modelRole: "plan", systemPrompt: `You are GaasbotDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index 54db502c..68357722 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -18,6 +18,7 @@ export const greybeardPackage: DirectorPackage = { }, nudge: { maxTurns: 50 }, modelRole: "review", + tier: "nested-orchestrator", systemPrompt: `You are GreybeardDirector, a specialist in Corbits Code. PRIMARY INTENT: architecture review. Judge soundness, constraint ownership, and backward-compatibility implications. Do not fix or ship product code. diff --git a/src/agent/directors/intern/package.ts b/src/agent/directors/intern/package.ts index 29b1fe42..8a92ffcc 100644 --- a/src/agent/directors/intern/package.ts +++ b/src/agent/directors/intern/package.ts @@ -21,6 +21,7 @@ export const internPackage: DirectorPackage = { optionalSkills: [], tools: { allow: INTERN_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 20 }, modelRole: "implement", systemPrompt: `You are InternDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/neckbeard/package.ts b/src/agent/directors/neckbeard/package.ts index 1f9e1ad8..9991ab0c 100644 --- a/src/agent/directors/neckbeard/package.ts +++ b/src/agent/directors/neckbeard/package.ts @@ -18,6 +18,7 @@ export const neckbeardPackage: DirectorPackage = { optionalSkills: ["style", "philosophy"], tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "review", systemPrompt: `You are NeckbeardDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts index 4353422e..2f70ecd0 100644 --- a/src/agent/directors/plan/package.ts +++ b/src/agent/directors/plan/package.ts @@ -9,6 +9,7 @@ export const planPackage: DirectorPackage = { optionalSkills: ["style", "philosophy", "interview"], tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "plan", systemPrompt: `You are PlanDirector, a specialist in Corbits Code. diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index 480d1485..75e1a40b 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -22,6 +22,7 @@ import { type DirectorPackage, type ResolveDirectorInput, type ResolveDirectorResult, + type SubagentTier, type TaskIntent, } from "./types.js"; @@ -61,6 +62,11 @@ export function isDirectorId(value: unknown): value is DirectorId { return typeof value === "string" && (DIRECTOR_IDS as readonly string[]).includes(value); } +/** Fleet authority tier for a closed director id, or undefined for non-director profiles. */ +export function tierForDirectorId(id: string): SubagentTier | undefined { + return isDirectorId(id) ? DIRECTOR_REGISTRY[id].tier : undefined; +} + export function listDirectors(): readonly DirectorPackage[] { return DIRECTOR_IDS.map((id) => DIRECTOR_REGISTRY[id]); } diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts index c2519e8f..dcb34ada 100644 --- a/src/agent/directors/shakespeare/package.ts +++ b/src/agent/directors/shakespeare/package.ts @@ -74,6 +74,7 @@ export const shakespearePackage: DirectorPackage = { optionalSkills: ["style", "philosophy"], tools: { allow: DOCS_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 50 }, modelRole: "docs", }; diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 629781ff..1501d061 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -193,4 +193,5 @@ export const skywalkerPackage: DirectorPackage = { }, nudge: { maxTurns: 100 }, modelRole: "orchestrator", + tier: "orchestrator", }; diff --git a/src/agent/directors/tester/package.ts b/src/agent/directors/tester/package.ts index 5580ae5e..8190dd57 100644 --- a/src/agent/directors/tester/package.ts +++ b/src/agent/directors/tester/package.ts @@ -30,6 +30,7 @@ If tests fail: document failures, suspected area, and blockers. Suggest a re-dis OUT OF LANE: fixing product code, "just quickly" fixing, redesigning the whole suite as Testsmith's primary job, fleet orchestration.`, tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "test", }; diff --git a/src/agent/directors/testsmith/package.ts b/src/agent/directors/testsmith/package.ts index 4a5cc49c..da6c8251 100644 --- a/src/agent/directors/testsmith/package.ts +++ b/src/agent/directors/testsmith/package.ts @@ -32,6 +32,7 @@ OUT OF LANE: fixing production code, becoming the implementer, running the full Read and search the codebase to ground the design; you have no product-mutation tools.`, tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, + tier: "leaf", nudge: { maxTurns: 40 }, modelRole: "test", }; diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index ba9c8b63..7b974f09 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -24,6 +24,18 @@ export type DirectorId = (typeof DIRECTOR_IDS)[number]; export type TaskIntent = "explore" | "implement" | "plan" | "review" | "general"; +/** + * Fleet authority tier (CL-6941). Runtime-enforced at the tool-mount point in + * subagent/run.ts and by subagent/authority.ts — never by prompt wording. + * + * - "orchestrator": Tier 1, primary (skywalker). Full fleet control over the + * whole tree. + * - "nested-orchestrator": Tier 2, scoped to its own subtree (e.g. greybeard). + * May manage only its own descendants, never siblings or ancestors. + * - "leaf": Tier 3, worker bee. No fleet verbs at all. + */ +export type SubagentTier = "orchestrator" | "nested-orchestrator" | "leaf"; + /** Static model-role tag for CL-5816 stub resolution (not a full package yet). */ export type ModelRole = "orchestrator" | "implement" | "explore" | "review" | "plan" | "docs" | "test"; @@ -67,6 +79,8 @@ export interface DirectorPackage { readonly spawn: SpawnRights; readonly nudge?: NudgePolicy; readonly modelRole: ModelRole; + /** Fleet authority tier — data on the package, gated at mount, not prose. */ + readonly tier: SubagentTier; } export interface ResolveDirectorInput { diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts new file mode 100644 index 00000000..cd1e6948 --- /dev/null +++ b/src/subagent/authority.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { + assertCanTargetAgent, + assertTierMayMountFleetVerb, + FleetAuthorityError, + isFleetVerb, +} from "./authority.js"; + +describe("assertTierMayMountFleetVerb", () => { + test("a Tier 3 leaf cannot obtain a fleet verb", () => { + expect(() => assertTierMayMountFleetVerb("leaf", "task")).toThrow(FleetAuthorityError); + expect(() => assertTierMayMountFleetVerb("leaf", "search_agents")).toThrow(FleetAuthorityError); + expect(() => assertTierMayMountFleetVerb("leaf", "spawn_agent")).toThrow(FleetAuthorityError); + }); + + test("leaves may still mount non-fleet tools", () => { + expect(() => assertTierMayMountFleetVerb("leaf", "read_file")).not.toThrow(); + }); + + test("Tier 1 and Tier 2 may mount fleet verbs", () => { + expect(() => assertTierMayMountFleetVerb("orchestrator", "task")).not.toThrow(); + expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "task")).not.toThrow(); + }); + + test("isFleetVerb matches the same set used for the gate", () => { + expect(isFleetVerb("task")).toBe(true); + expect(isFleetVerb("write_file")).toBe(false); + }); +}); + +describe("assertCanTargetAgent", () => { + // Tree: skywalker(root) -> greybeard -> intern + // -> build (sibling of greybeard) + const nodes = [ + { id: "skywalker-session" }, + { id: "greybeard-session", parentSessionId: "skywalker-session" }, + { id: "intern-session", parentSessionId: "greybeard-session" }, + { id: "build-session", parentSessionId: "skywalker-session" }, + ]; + + test("Tier 1 primary orchestrator can target anyone in the tree", () => { + const skywalker = { id: "skywalker-session", tier: "orchestrator" as const }; + expect(() => assertCanTargetAgent(skywalker, "greybeard-session", nodes)).not.toThrow(); + expect(() => assertCanTargetAgent(skywalker, "intern-session", nodes)).not.toThrow(); + expect(() => assertCanTargetAgent(skywalker, "build-session", nodes)).not.toThrow(); + }); + + test("Tier 2 nested orchestrator can target its own descendant", () => { + const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const }; + expect(() => assertCanTargetAgent(greybeard, "intern-session", nodes)).not.toThrow(); + }); + + test("Tier 2 nested orchestrator can target itself", () => { + const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const }; + expect(() => assertCanTargetAgent(greybeard, "greybeard-session", nodes)).not.toThrow(); + }); + + test("Tier 2 nested orchestrator cannot target a sibling", () => { + const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const }; + expect(() => assertCanTargetAgent(greybeard, "build-session", nodes)).toThrow( + FleetAuthorityError, + ); + }); + + test("Tier 2 nested orchestrator cannot target an ancestor", () => { + const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const }; + expect(() => assertCanTargetAgent(greybeard, "skywalker-session", nodes)).toThrow( + FleetAuthorityError, + ); + }); + + test("Tier 3 leaf cannot target any agent, even itself", () => { + const intern = { id: "intern-session", tier: "leaf" as const }; + expect(() => assertCanTargetAgent(intern, "intern-session", nodes)).toThrow( + FleetAuthorityError, + ); + }); +}); diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts new file mode 100644 index 00000000..20a9a015 --- /dev/null +++ b/src/subagent/authority.ts @@ -0,0 +1,115 @@ +/** + * Fleet authority (CL-6941): the runtime boundary between the three tiers. + * + * Tier enforcement lives here and at the tool-mount point in run.ts — never + * in a prompt. This module owns two checks: + * + * - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb + * (today: task, search_agents; the spawn_agent/wait_agents/list_agents/ + * send_input/interrupt_agent/close_agent/resume_agent/read_agent_trace + * verbs land in later child issues against this same gate). + * - assertCanTargetAgent: a Tier 2 nested orchestrator may act only on its + * own descendants, never a sibling or anything above it in the tree. + * Tier 1 (the primary orchestrator) may target anyone. Callers pass the + * live fleet as a flat list of {id, parentSessionId} nodes — the same + * shape SubAgentSessionStore already tracks — so no parallel tree + * structure is needed. + */ + +import type { SubagentTier } from "../agent/directors/types.js"; + +export type { SubagentTier } from "../agent/directors/types.js"; + +/** + * Every tool that grants control over other agents (spawn, list, steer, + * observe). Tier 3 leaves may mount none of these — ever. Verbs not yet + * implemented are listed here so their eventual mount sites inherit the gate + * for free instead of needing a second allowlist. + */ +export const FLEET_VERBS = new Set([ + "task", + "search_agents", + "spawn_agent", + "wait_agents", + "list_agents", + "send_input", + "interrupt_agent", + "close_agent", + "resume_agent", + "read_agent_trace", + "followup_task", +]); + +export function isFleetVerb(toolName: string): boolean { + return FLEET_VERBS.has(toolName); +} + +export class FleetAuthorityError extends Error { + constructor(message: string) { + super(message); + this.name = "FleetAuthorityError"; + } +} + +/** + * Guard at the tool-mount point: throws if a Tier 3 leaf is about to receive + * a fleet verb. Call this where tools are assembled (run.ts), not from a + * prompt instruction — a leaf must never even hold the tool. + */ +export function assertTierMayMountFleetVerb(tier: SubagentTier, toolName: string): void { + if (tier === "leaf" && isFleetVerb(toolName)) { + throw new FleetAuthorityError( + `Tier 3 leaf directors cannot mount fleet verb "${toolName}". ` + + `Leaves get ask_director / submit_result / progress_note only.`, + ); + } +} + +/** Minimal shape of a live fleet member — matches SubAgentSessionStore records. */ +export interface FleetNode { + readonly id: string; + readonly parentSessionId?: string | undefined; +} + +function isDescendant( + nodes: readonly FleetNode[], + ancestorId: string, + candidateId: string, +): boolean { + const byId = new Map(nodes.map((n) => [n.id, n] as const)); + let cursor = byId.get(candidateId); + const seen = new Set(); + while (cursor?.parentSessionId !== undefined && !seen.has(cursor.id)) { + seen.add(cursor.id); + if (cursor.parentSessionId === ancestorId) return true; + cursor = byId.get(cursor.parentSessionId); + } + return false; +} + +/** + * Authority rule (root owns its tree; a child manages only its own + * descendants): throws unless `actor` is Tier 1, or `targetId` is `actor.id` + * itself, or a descendant of `actor.id` in `nodes`. A Tier 3 leaf holds no + * fleet verbs at all and can never reach this check with a real call, so it + * always fails closed here too. + */ +export function assertCanTargetAgent( + actor: { readonly id: string; readonly tier: SubagentTier }, + targetId: string, + nodes: readonly FleetNode[], +): void { + if (actor.tier === "leaf") { + throw new FleetAuthorityError( + `Tier 3 leaf "${actor.id}" holds no fleet verbs and cannot target any agent.`, + ); + } + if (actor.tier === "orchestrator") return; + if (actor.id === targetId) return; + if (!isDescendant(nodes, actor.id, targetId)) { + throw new FleetAuthorityError( + `Tier 2 nested orchestrator "${actor.id}" may only target its own descendants; ` + + `"${targetId}" is a sibling or ancestor, outside its subtree.`, + ); + } +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index aa2049bd..36a32d0d 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -104,6 +104,8 @@ import { resolveSubAgentDeadlineMs, } from "./stop-policy.js"; import { SubAgentDirector } from "./nudge-director.js"; +import { assertTierMayMountFleetVerb } from "./authority.js"; +import { tierForDirectorId } from "../agent/directors/registry.js"; import { abortError, createSubAgentSpawnRegistryPlugin, @@ -425,6 +427,18 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { // the prompt. Nested dispatch always forbids further orchestration so the // tree bottoms out after one hop. if (params.orchestrator === true) { + // Tier enforcement at the mount point (CL-6941), not the prompt: a + // closed director resolved to Tier 3 must never reach here holding + // orchestrator=true — fail closed instead of silently installing fleet + // verbs on a leaf. Non-director profiles (params.directorId unset) + // are outside the closed-director tier system and are not checked here. + const tier = + params.directorId !== undefined ? tierForDirectorId(params.directorId) : undefined; + if (tier !== undefined) { + for (const verb of ["task", "search_agents"]) { + assertTierMayMountFleetVerb(tier, verb); + } + } if (params.nestedDispatch === undefined) { throw new Error( "runSubAgent: orchestrator=true requires nestedDispatch so the task tool can be installed", From 03185d662fbdee04db7ef35c0a84a16eec0a313e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 21:34:55 -0700 Subject: [PATCH 2/3] Mark assertCanTargetAgent as an unwired seam, not a live gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It has no production call site yet — no verb today lets one live agent address another, so the subtree rule is exercised only by its test. Flagged in code and docs so it is not mistaken for enforced, the same trap writePaths/report.requiredSections/the --config comment/the thrash matcher fell into. assertTierMayMountFleetVerb is unaffected and remains wired at src/subagent/run.ts. --- docs/ARCHITECTURE.md | 6 +++--- src/subagent/authority.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea3cb3e2..977060e3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -239,9 +239,9 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent 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: -- **Mount-time gate.** `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. -- **Subtree authority.** `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 (walked over the same `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks — no parallel tree), Tier 3 holds no fleet verbs and always fails closed. This is the authority primitive the fleet-targeting verbs land against in later child issues (CL-6942, CL-6951, CL-6945, CL-6946); CL-6941 lands the boundary and its tests, not the verbs. -- `task()` is unaffected and remains the only spawn verb until the new verbs land beside it (deprecated-not-deleted per the CL-6940 epic). +- **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. +- **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. +- `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. #### Closed director fleet (`src/agent/directors/`) diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts index 20a9a015..6c760c53 100644 --- a/src/subagent/authority.ts +++ b/src/subagent/authority.ts @@ -88,6 +88,16 @@ function isDescendant( } /** + * SEAM, NOT YET A LIVE GATE: this function has no production call site today. + * No verb in this codebase currently lets one live agent target another + * (`task` only spawns; it never addresses an existing session), so the + * subtree rule below is exercised only by authority.test.ts — it is not + * enforced at runtime yet. It exists now so CL-6942 (split spawn from wait) + * and CL-6944 (send_input steering) — the first two verbs that make one + * agent addressable by another — can call it from day one instead of + * inventing their own check. Until one of those wires a call site here, do + * not describe this rule as enforced; only assertTierMayMountFleetVerb is. + * * Authority rule (root owns its tree; a child manages only its own * descendants): throws unless `actor` is Tier 1, or `targetId` is `actor.id` * itself, or a descendant of `actor.id` in `nodes`. A Tier 3 leaf holds no From 5b954db27a54294eb9c506c702879ebecbfb9d6d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 21:48:50 -0700 Subject: [PATCH 3/3] Fail closed on unresolvable fleet tier at the mount point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 14 +++-- docs/ARCHITECTURE.md | 2 +- src/agent/directors/registry.test.ts | 15 +++++ src/agent/profile-types.ts | 12 ++++ src/agent/profiles.ts | 1 + src/subagent/run-authority.test.ts | 83 ++++++++++++++++++++++++++++ src/subagent/run.ts | 23 ++++---- src/subagent/task-tool.ts | 29 +++++++++- src/subagent/types.ts | 10 ++++ 9 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 src/subagent/run-authority.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a80e4c08..db4373ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename Every director package carries a required `tier` (`orchestrator` / `nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard (and any package with `spawn.maySpawn`) is scoped to its own subtree, and every - other director gets no fleet verbs at all. The check lives in code - (`src/subagent/authority.ts`, wired into `runSubAgent`'s tool-mount point) so a - leaf cannot obtain a fleet verb and a nested orchestrator cannot reach a - sibling or ancestor — this is the foundation the next fleet-control verbs land - against. `task()` is unchanged and still the only spawn verb. + other director gets no fleet verbs at all. The gate lives in code + (`src/subagent/authority.ts`, wired into `runSubAgent`'s tool-mount point) and + fails closed: a caller whose tier cannot be resolved — including a + project-local or plugin agent profile with `orchestrator: true` that has not + explicitly opted in via `fleetTier: "nested-orchestrator"` — is denied + `task`/`search_agents` rather than silently trusted. This is the foundation + the next fleet-control verbs (spawn/list/steer a live agent) land against; + the subtree-scoping rule for those is written and tested but not yet wired to + a live call site. `task()` is unchanged and still the only spawn verb. ## [0.2.107] - 2026-08-24 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 977060e3..efd7fbff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -239,7 +239,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent 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: -- **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. +- **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. - **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. - `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. diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 822b7da5..2e1c8647 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -9,6 +9,7 @@ import { listDirectors, packageToProfile, resolveDirector, + tierForDirectorId, } from "./registry.js"; describe("director registry", () => { @@ -170,6 +171,20 @@ describe("director registry", () => { expect(s.spawn.allowlist).toHaveLength(15); }); + // CL-6941: tier and spawn.maySpawn independently encode "may this package + // spawn", hand-set across 16 files. This pins their agreement so drift + // (adding maySpawn: true without bumping tier, or vice versa) fails a test + // instead of surfacing as an unexplained FleetAuthorityError at dispatch. + test("tier agrees with spawn.maySpawn for every director", () => { + for (const id of DIRECTOR_IDS) { + const pkg = DIRECTOR_REGISTRY[id]; + expect(pkg.tier !== "leaf").toBe(pkg.spawn.maySpawn); + expect(tierForDirectorId(id)).toBe(pkg.tier); + } + expect(DIRECTOR_REGISTRY.skywalker.tier).toBe("orchestrator"); + expect(DIRECTOR_REGISTRY.greybeard.tier).toBe("nested-orchestrator"); + }); + test("every director profile declares matching agent id in system prompt", () => { for (const id of DIRECTOR_IDS) { const profile = packageToProfile(DIRECTOR_REGISTRY[id]); diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index a2bd14ec..8ec167d0 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -68,6 +68,18 @@ export interface AgentProfile { // coordinators (e.g. a planning agent that fans work out to specialists); // leaf-task agents should leave this unset. orchestrator?: boolean; + // Fleet authority tier (CL-6941) for a profile-sourced orchestrator. Only + // meaningful alongside orchestrator: true. Named `fleetTier`, not `tier` — + // `tier` is already an established profile field for model speed selection + // ("fast" | "standard" | "clever", resolved via task(tier=...)); reusing + // the name silently broke schema validation for profiles that set it. A + // profile is outside the closed director set, so it is NOT trusted with + // fleet verbs by default even when orchestrator: true is set — this must + // be declared explicitly as "nested-orchestrator" to opt in. + // Runtime-enforced at the tool-mount point (src/subagent/run.ts / + // src/subagent/authority.ts): an orchestrator=true profile with no + // fleetTier (or fleetTier: "leaf") is denied task/search_agents, fail-closed. + fleetTier?: "orchestrator" | "nested-orchestrator" | "leaf"; // Optional inference-turn budget when this profile is dispatched via task(agent=...). // Floor-sanitized (≥1) at dispatch time; task(maxTurns) overrides when set. maxTurns?: number; diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index 5a57a424..a27f8fac 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -51,6 +51,7 @@ const AgentProfileSchema = type({ "systemPromptRole?": "string", "systemPromptPath?": "string", "orchestrator?": "boolean", + "fleetTier?": "'orchestrator' | 'nested-orchestrator' | 'leaf'", "maxTurns?": "number", }); diff --git a/src/subagent/run-authority.test.ts b/src/subagent/run-authority.test.ts new file mode 100644 index 00000000..4229b065 --- /dev/null +++ b/src/subagent/run-authority.test.ts @@ -0,0 +1,83 @@ +/** + * Gate-level proof for CL-6941: authority.test.ts proves the assert + * functions throw when called directly, which is necessary but not + * sufficient — it does not prove runSubAgent itself cannot be talked into + * mounting a fleet verb for a caller whose tier cannot be established. These + * tests drive runSubAgent (the real mount point) end to end. + */ + +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPermissionGate } from "../permission/gate.js"; +import { FleetAuthorityError } from "./authority.js"; +import { runSubAgent } from "./run.js"; +import type { RunSubAgentParams } from "./types.js"; + +const testPermissionGate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, +}); + +async function tmpCwd(): Promise { + return mkdtemp(join(tmpdir(), "cl6941-run-authority-")); +} + +function baseParams(cwd: string, workdirBase: string): Omit { + return { + cwd, + workdirBase, + permissionGate: testPermissionGate, + provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" }, + description: "gate probe", + prompt: "no-op", + }; +} + +describe("runSubAgent fleet-verb mount gate (CL-6941, fails closed)", () => { + test("orchestrator=true with no resolvable tier (non-closed-director profile shape) is denied", async () => { + const cwd = await tmpCwd(); + await expect( + runSubAgent({ + ...baseParams(cwd, join(cwd, ".ctx")), + orchestrator: true, + // No directorId, no orchestratorTier — this is exactly the shape a + // project/plugin AgentProfile with orchestrator: true produces. + // nestedDispatch is deliberately omitted: the tier gate must reject + // before that later "requires nestedDispatch" check is even reached. + }), + ).rejects.toBeInstanceOf(FleetAuthorityError); + }); + + test("orchestrator=true with an explicit leaf tier is denied", async () => { + const cwd = await tmpCwd(); + await expect( + runSubAgent({ + ...baseParams(cwd, join(cwd, ".ctx")), + orchestrator: true, + orchestratorTier: "leaf", + }), + ).rejects.toBeInstanceOf(FleetAuthorityError); + }); + + test("orchestrator=true with a resolved non-leaf tier passes the gate (fails later, not on authority)", async () => { + const cwd = await tmpCwd(); + try { + await runSubAgent({ + ...baseParams(cwd, join(cwd, ".ctx")), + orchestrator: true, + orchestratorTier: "nested-orchestrator", + // Deliberately still omit nestedDispatch: a tier that passes the gate + // must reach the *next* check (nestedDispatch required) instead of + // being denied by assertTierMayMountFleetVerb. + }); + throw new Error("expected runSubAgent to reject (missing nestedDispatch)"); + } catch (err) { + expect(err).not.toBeInstanceOf(FleetAuthorityError); + expect(String((err as Error).message)).toContain("nestedDispatch"); + } + }); +}); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 36a32d0d..83f73261 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -105,7 +105,6 @@ import { } from "./stop-policy.js"; import { SubAgentDirector } from "./nudge-director.js"; import { assertTierMayMountFleetVerb } from "./authority.js"; -import { tierForDirectorId } from "../agent/directors/registry.js"; import { abortError, createSubAgentSpawnRegistryPlugin, @@ -427,17 +426,17 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { // the prompt. Nested dispatch always forbids further orchestration so the // tree bottoms out after one hop. if (params.orchestrator === true) { - // Tier enforcement at the mount point (CL-6941), not the prompt: a - // closed director resolved to Tier 3 must never reach here holding - // orchestrator=true — fail closed instead of silently installing fleet - // verbs on a leaf. Non-director profiles (params.directorId unset) - // are outside the closed-director tier system and are not checked here. - const tier = - params.directorId !== undefined ? tierForDirectorId(params.directorId) : undefined; - if (tier !== undefined) { - for (const verb of ["task", "search_agents"]) { - assertTierMayMountFleetVerb(tier, verb); - } + // Tier enforcement at the mount point (CL-6941), not the prompt: FAILS + // CLOSED. The caller (task-tool.ts) resolves orchestratorTier from + // either the closed DirectorPackage.tier or an explicit + // AgentProfile.tier opt-in; an unresolved tier defaults to "leaf" here, + // not to "skip the check" — a caller that cannot be identified must be + // denied, never silently trusted. This is what stops a project/plugin + // AgentProfile with orchestrator: true from mounting task/search_agents + // just because it is outside the closed director set. + const tier = params.orchestratorTier ?? "leaf"; + for (const verb of ["task", "search_agents"]) { + assertTierMayMountFleetVerb(tier, verb); } if (params.nestedDispatch === undefined) { throw new Error( diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 336701e7..12f038d9 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -20,7 +20,7 @@ import { defaultEffortForDirector, formatDirectorSystemPrompt, } from "../agent/directors/identity.js"; -import type { DirectorPackage } from "../agent/directors/types.js"; +import type { DirectorPackage, SubagentTier } from "../agent/directors/types.js"; import type { Settings } from "../config/settings.js"; import { resolveSubAgentMaxTurns, @@ -348,6 +348,14 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { let capabilities: CapabilityFilter | undefined; let systemPromptRole: string | undefined; let orchestrator = false; + /** + * Fleet authority tier (CL-6941) for this dispatch — forwarded to + * runSubAgent, which fails closed (denies task/search_agents) when + * orchestrator is true and this is left undefined or resolves to + * "leaf". Set alongside `orchestrator = true` in every branch below; + * never left to default once orchestrator is true. + */ + let orchestratorTier: SubagentTier | undefined; let profileMaxTurns: number | undefined; let resolvedDirectorId: string | undefined; let resolvedPackage: DirectorPackage | undefined; @@ -423,6 +431,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { orchestrator = true; + orchestratorTier = pkg.tier; if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) { nestedSpawnAllowlist = pkg.spawn.allowlist; } @@ -476,6 +485,15 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { // even if their profile is marked orchestrator — recursion bottoms out. if (profile.orchestrator === true && deps.allowOrchestrator !== false) { orchestrator = true; + // Fail closed (CL-6941): a profile is outside the closed director + // set, so orchestrator: true alone does not grant a tier. Only an + // explicit non-leaf profile.fleetTier opts in; anything else + // (absent, or "leaf") leaves orchestratorTier undefined, which + // runSubAgent treats as "leaf" and denies task/search_agents. + orchestratorTier = + profile.fleetTier !== undefined && profile.fleetTier !== "leaf" + ? profile.fleetTier + : undefined; } // Per-agent pinned inference (provider/model/effort), if declared. // Resolution uses policy (mode: pin / agentModelFallback: none) so a @@ -510,6 +528,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { orchestrator = true; + orchestratorTier = pkg.tier; if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) { nestedSpawnAllowlist = pkg.spawn.allowlist; } @@ -802,7 +821,13 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ...(capabilities !== undefined ? { capabilities } : {}), ...(systemPromptRole !== undefined ? { systemPromptRole } : {}), ...(resolvedDirectorId !== undefined ? { directorId: resolvedDirectorId } : {}), - ...(orchestrator ? { orchestrator: true, nestedDispatch: nestedDispatch! } : {}), + ...(orchestrator + ? { + orchestrator: true, + ...(orchestratorTier !== undefined ? { orchestratorTier } : {}), + nestedDispatch: nestedDispatch!, + } + : {}), maxTurns: resolvedMaxTurns, ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), }; diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 1522eaeb..b247abbe 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -16,6 +16,7 @@ import type { PermissionGate } from "../permission/gate.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import type { SubAgentSessionStore } from "./session-store.js"; import type { TaskIntent } from "./report.js"; +import type { SubagentTier } from "../agent/directors/types.js"; export interface SubAgentProvider { providerName: string; @@ -106,6 +107,15 @@ export type RunSubAgentParams = { // Requires nestedDispatch so the task tool can actually be installed — // advertising permission without the tool is a hard break. orchestrator?: boolean; + /** + * Fleet authority tier (CL-6941) for this dispatch, resolved by the caller + * (task-tool.ts) from either the closed DirectorPackage.tier or an explicit + * AgentProfile.tier opt-in. Required whenever orchestrator is true: + * runSubAgent fails closed (denies task/search_agents) when orchestrator is + * true and this is undefined or "leaf" — an unrecognized or unresolved tier + * must never mount a fleet verb. See src/subagent/authority.ts. + */ + orchestratorTier?: SubagentTier; // Present only when orchestrator is true. Installs task + search_agents so // the orchestrator can actually dispatch workers. nestedDispatch?: NestedDispatchDeps;