diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb03c0b..db4373ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ 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 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 ### Agent diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48ec68e9..efd7fbff 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 — 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. + #### 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.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/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/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/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..6c760c53 --- /dev/null +++ b/src/subagent/authority.ts @@ -0,0 +1,125 @@ +/** + * 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; +} + +/** + * 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 + * 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-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 aa2049bd..83f73261 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -104,6 +104,7 @@ import { resolveSubAgentDeadlineMs, } from "./stop-policy.js"; import { SubAgentDirector } from "./nudge-director.js"; +import { assertTierMayMountFleetVerb } from "./authority.js"; import { abortError, createSubAgentSpawnRegistryPlugin, @@ -425,6 +426,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: 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( "runSubAgent: orchestrator=true requires nestedDispatch so the task tool can be installed", 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;