From 27ee93252c871ba6bdcbd8787aace3699540a0fa Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 15:20:35 -0700 Subject: [PATCH 1/3] Restrict fleet discovery tools to Tier-1 skywalker Nested orchestrators keep task/spawn allowlists but must not mount search_agents or list_agents; gate that at tool-set and mount time. --- src/agent/directors/greybeard/package.test.ts | 6 +- src/agent/directors/tool-sets.test.ts | 7 ++ src/agent/directors/tool-sets.ts | 5 +- src/subagent/authority.test.ts | 20 ++++- src/subagent/authority.ts | 30 +++++-- src/subagent/run-authority.test.ts | 86 +++++++++++++++++++ src/subagent/run.ts | 12 +-- 7 files changed, 150 insertions(+), 16 deletions(-) diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index d1e39235f..b02862411 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -88,15 +88,17 @@ describe("greybeardPackage", () => { expect(allow).not.toContain("counsel"); }); - test("tools.allow is orchestrator surface with product writes", () => { + test("tools.allow is orchestrator surface with product writes but without fleet discovery", () => { const allow = greybeardPackage.tools?.allow ?? []; expect(allow).toContain("task"); - expect(allow).toContain("search_agents"); + // CL-7051: search_agents is Skywalker-only — nested directors spawn from allowlist. + expect(allow).not.toContain("search_agents"); expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); expect(allow).toContain("delete_file"); }); + test("modelRole is review", () => { expect(greybeardPackage.modelRole).toBe("review"); }); diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts index 2e73f12ae..bf9d8dde6 100644 --- a/src/agent/directors/tool-sets.test.ts +++ b/src/agent/directors/tool-sets.test.ts @@ -70,6 +70,12 @@ describe("SKYWALKER_TOOLS / ORCHESTRATOR_TOOLS", () => { expect(SKYWALKER_TOOLS).toContain("task"); expect(ORCHESTRATOR_TOOLS).toContain("task"); }); + + // CL-7051: fleet discovery is Tier-1 only. + test("search_agents is on Skywalker only, not the nested orchestrator surface", () => { + expect(SKYWALKER_TOOLS as readonly string[]).toContain("search_agents"); + expect(ORCHESTRATOR_TOOLS as readonly string[]).not.toContain("search_agents"); + }); }); describe("REVIEW_TOOLS / INTERN_TOOLS", () => { @@ -90,6 +96,7 @@ describe("REVIEW_TOOLS / INTERN_TOOLS", () => { }); }); + describe("BUILD_TOOLS", () => { test("includes apply_patch alongside path mutation tools", () => { expect(BUILD_TOOLS).toContain("write_file"); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 7cd5b1a4d..4ec091d28 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -65,9 +65,8 @@ export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir", ...PRODUCT_WR export const ORCHESTRATOR_TOOLS = [ ...READ_TOOLS, ...PRODUCT_WRITE_TOOLS, - "search_agents", "task", ] as const; -/** Skywalker primary: orchestrator surface (writes already composed). */ -export const SKYWALKER_TOOLS = [...ORCHESTRATOR_TOOLS] as const; +/** Skywalker primary: orchestrator surface plus fleet discovery (Tier-1 only). */ +export const SKYWALKER_TOOLS = [...ORCHESTRATOR_TOOLS, "search_agents"] as const; diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts index f38ca8df9..1c4de419a 100644 --- a/src/subagent/authority.test.ts +++ b/src/subagent/authority.test.ts @@ -25,9 +25,26 @@ describe("assertTierMayMountFleetVerb", () => { expect(() => assertTierMayMountFleetVerb("leaf", "read_file")).not.toThrow(); }); - test("Tier 1 and Tier 2 may mount fleet verbs", () => { + test("Tier 1 and Tier 2 may mount spawn/control fleet verbs", () => { expect(() => assertTierMayMountFleetVerb("orchestrator", "task")).not.toThrow(); expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "task")).not.toThrow(); + expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "spawn_agent")).not.toThrow(); + }); + + // CL-7051: fleet discovery is Skywalker (Tier 1) only — nested directors keep + // task/spawn allowlists but must not discover the full fleet. + test("Tier 2 nested orchestrator cannot mount search_agents or list_agents", () => { + expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "search_agents")).toThrow( + FleetAuthorityError, + ); + expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "list_agents")).toThrow( + FleetAuthorityError, + ); + }); + + test("Tier 1 orchestrator may mount search_agents and list_agents", () => { + expect(() => assertTierMayMountFleetVerb("orchestrator", "search_agents")).not.toThrow(); + expect(() => assertTierMayMountFleetVerb("orchestrator", "list_agents")).not.toThrow(); }); test("isFleetVerb matches the same set used for the gate", () => { @@ -36,6 +53,7 @@ describe("assertTierMayMountFleetVerb", () => { }); }); + describe("assertCanTargetAgent", () => { // Tree: skywalker(root) -> greybeard -> intern // -> build (sibling of greybeard) diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts index 42e8fc465..3a042eb52 100644 --- a/src/subagent/authority.ts +++ b/src/subagent/authority.ts @@ -8,7 +8,9 @@ * (today: task, search_agents, read_agent_trace; the spawn_agent/ * wait_agents/list_agents/send_input/interrupt_agent/close_agent/ * resume_agent/followup_task verbs land in later child issues against - * this same gate). + * this same gate). Fleet *discovery* verbs (search_agents, list_agents) + * are further restricted to Tier 1 only (CL-7051) — nested orchestrators + * keep task/spawn allowlists but must not discover the full fleet. * - 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 @@ -41,10 +43,20 @@ export const FLEET_VERBS = new Set([ "followup_task", ]); +/** + * Fleet discovery — Tier 1 (skywalker) only. Nested orchestrators spawn from + * a closed allowlist and must not index the full fleet (CL-7051). + */ +export const ORCHESTRATOR_ONLY_FLEET_VERBS = new Set(["search_agents", "list_agents"]); + export function isFleetVerb(toolName: string): boolean { return FLEET_VERBS.has(toolName); } +export function isOrchestratorOnlyFleetVerb(toolName: string): boolean { + return ORCHESTRATOR_ONLY_FLEET_VERBS.has(toolName); +} + export class FleetAuthorityError extends Error { constructor(message: string) { super(message); @@ -53,17 +65,25 @@ export class FleetAuthorityError extends Error { } /** - * 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. + * Guard at the tool-mount point: throws if the caller's tier may not receive + * this fleet verb. Call this where tools are assembled (run.ts), not from a + * prompt instruction — a leaf must never even hold the tool; a nested + * orchestrator must never hold fleet-discovery verbs. */ export function assertTierMayMountFleetVerb(tier: SubagentTier, toolName: string): void { - if (tier === "leaf" && isFleetVerb(toolName)) { + if (!isFleetVerb(toolName)) return; + if (tier === "leaf") { throw new FleetAuthorityError( `Tier 3 leaf directors cannot mount fleet verb "${toolName}". ` + `Leaves get ask_director / submit_result / progress_note only.`, ); } + if (tier === "nested-orchestrator" && isOrchestratorOnlyFleetVerb(toolName)) { + throw new FleetAuthorityError( + `Tier 2 nested orchestrators cannot mount fleet discovery verb "${toolName}". ` + + `Only Tier 1 (skywalker) may discover the fleet; nested directors spawn from their allowlist.`, + ); + } } /** Minimal shape of a live fleet member — matches SubAgentSessionStore records. */ diff --git a/src/subagent/run-authority.test.ts b/src/subagent/run-authority.test.ts index 3dfbd3e9b..3d674bc7c 100644 --- a/src/subagent/run-authority.test.ts +++ b/src/subagent/run-authority.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from "node:os"; import { mkdtemp } from "node:fs/promises"; import { join } from "node:path"; +import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; import { createPermissionGate } from "../permission/gate.js"; import { FleetAuthorityError } from "./authority.js"; import { runSubAgent } from "./run.js"; @@ -81,3 +82,88 @@ describe("runSubAgent fleet-verb mount gate (CL-6941, fails closed)", () => { } }); }); + +describe("runSubAgent search_agents mount gate (CL-7051, Tier-1 only)", () => { + test("nested-orchestrator does not mount search_agents even when profiles exist", async () => { + const cwd = await tmpCwd(); + let searchAgentsMounts = 0; + + await withMockedModuleDuring( + import.meta.resolve("../agent/agent-search.js"), + (real: typeof import("../agent/agent-search.js")) => ({ + ...real, + createSearchAgentsTool: (getProfiles: () => never) => { + searchAgentsMounts++; + return real.createSearchAgentsTool(getProfiles); + }, + }), + async () => { + // Re-import so the mock is visible to runSubAgent's binding. + const { runSubAgent: run } = await import("./run.js"); + try { + await run({ + ...baseParams(cwd, join(cwd, ".ctx")), + id: "greybeard-session", + orchestrator: true, + orchestratorTier: "nested-orchestrator", + nestedDispatch: { + permissionGate: testPermissionGate, + getWorkdirBase: () => join(cwd, ".ctx"), + provider: { + providerName: "test", + baseURL: "http://localhost", + model: "test-model", + }, + profiles: [{ id: "intern", systemPromptRole: "You are intern." }], + }, + }); + } catch { + // Inference/agent construction may fail; mount decisions run first. + } + }, + ); + + expect(searchAgentsMounts).toBe(0); + }); + + test("Tier-1 orchestrator mounts search_agents when profiles exist", async () => { + const cwd = await tmpCwd(); + let searchAgentsMounts = 0; + + await withMockedModuleDuring( + import.meta.resolve("../agent/agent-search.js"), + (real: typeof import("../agent/agent-search.js")) => ({ + ...real, + createSearchAgentsTool: (getProfiles: () => never) => { + searchAgentsMounts++; + return real.createSearchAgentsTool(getProfiles); + }, + }), + async () => { + const { runSubAgent: run } = await import("./run.js"); + try { + await run({ + ...baseParams(cwd, join(cwd, ".ctx")), + id: "skywalker-session", + orchestrator: true, + orchestratorTier: "orchestrator", + nestedDispatch: { + permissionGate: testPermissionGate, + getWorkdirBase: () => join(cwd, ".ctx"), + provider: { + providerName: "test", + baseURL: "http://localhost", + model: "test-model", + }, + profiles: [{ id: "intern", systemPromptRole: "You are intern." }], + }, + }); + } catch { + // Inference/agent construction may fail; mount decisions run first. + } + }, + ); + + expect(searchAgentsMounts).toBe(1); + }); +}); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 2f81bc89e..8c7cdd467 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -466,18 +466,20 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { const profiles = nd.profiles; From c3c87e9801bdf42f57d5e2c1e84ad38092b633d4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 15:34:55 -0700 Subject: [PATCH 2/3] Fix prettier formatting for Tier-1 discovery gate --- src/agent/directors/greybeard/package.test.ts | 1 - src/agent/directors/tool-sets.test.ts | 1 - src/subagent/authority.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index b02862411..447d3b506 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -98,7 +98,6 @@ describe("greybeardPackage", () => { expect(allow).toContain("delete_file"); }); - test("modelRole is review", () => { expect(greybeardPackage.modelRole).toBe("review"); }); diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts index bf9d8dde6..ae1369a0b 100644 --- a/src/agent/directors/tool-sets.test.ts +++ b/src/agent/directors/tool-sets.test.ts @@ -96,7 +96,6 @@ describe("REVIEW_TOOLS / INTERN_TOOLS", () => { }); }); - describe("BUILD_TOOLS", () => { test("includes apply_patch alongside path mutation tools", () => { expect(BUILD_TOOLS).toContain("write_file"); diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts index 1c4de419a..77529dbe9 100644 --- a/src/subagent/authority.test.ts +++ b/src/subagent/authority.test.ts @@ -53,7 +53,6 @@ describe("assertTierMayMountFleetVerb", () => { }); }); - describe("assertCanTargetAgent", () => { // Tree: skywalker(root) -> greybeard -> intern // -> build (sibling of greybeard) From 3d7a416f231e5984a6730cb34335b224b5556bb4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 18:43:57 -0700 Subject: [PATCH 3/3] Fix prettier formatting after Tier-1 discovery rebase --- src/agent/directors/tool-sets.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 4ec091d28..bf4f72a88 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -62,11 +62,7 @@ export const REVIEW_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS] as const; export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir", ...PRODUCT_WRITE_TOOLS] as const; /** Nested orchestrator surface (greybeard / package filter): dispatch + path writes. */ -export const ORCHESTRATOR_TOOLS = [ - ...READ_TOOLS, - ...PRODUCT_WRITE_TOOLS, - "task", -] as const; +export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS, "task"] as const; /** Skywalker primary: orchestrator surface plus fleet discovery (Tier-1 only). */ export const SKYWALKER_TOOLS = [...ORCHESTRATOR_TOOLS, "search_agents"] as const;