Skip to content

Commit 873a290

Browse files
committed
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.
1 parent 02a3f85 commit 873a290

7 files changed

Lines changed: 152 additions & 15 deletions

File tree

src/agent/directors/greybeard/package.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,17 @@ describe("greybeardPackage", () => {
4444
expect(greybeardPackage.systemPrompt).toMatch(/never spawn a parallel diagnostic fleet/i);
4545
});
4646

47-
test("tools.allow is orchestrator surface without product writes", () => {
47+
test("tools.allow is orchestrator surface without product writes or fleet discovery", () => {
4848
const allow = greybeardPackage.tools?.allow ?? [];
4949
expect(allow).toContain("task");
50-
expect(allow).toContain("search_agents");
50+
// CL-7051: search_agents is Skywalker-only — nested directors spawn from allowlist.
51+
expect(allow).not.toContain("search_agents");
5152
expect(allow).not.toContain("write_file");
5253
expect(allow).not.toContain("edit_file");
5354
expect(allow).not.toContain("delete_file");
5455
});
5556

57+
5658
test("modelRole is review", () => {
5759
expect(greybeardPackage.modelRole).toBe("review");
5860
});

src/agent/directors/tool-sets.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,15 @@ describe("SKYWALKER_TOOLS / ORCHESTRATOR_TOOLS", () => {
5252
expect(SKYWALKER_TOOLS).toContain("task");
5353
expect(ORCHESTRATOR_TOOLS).toContain("task");
5454
});
55+
56+
// CL-7051: fleet discovery is Tier-1 only.
57+
test("search_agents is on Skywalker only, not the nested orchestrator surface", () => {
58+
expect(SKYWALKER_TOOLS as readonly string[]).toContain("search_agents");
59+
expect(ORCHESTRATOR_TOOLS as readonly string[]).not.toContain("search_agents");
60+
});
5561
});
5662

63+
5764
describe("BUILD_TOOLS", () => {
5865
test("includes apply_patch alongside path mutation tools", () => {
5966
expect(BUILD_TOOLS).toContain("write_file");

src/agent/directors/tool-sets.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,14 @@ export const REVIEW_TOOLS = [...READ_TOOLS] as const;
5858
export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir"] as const;
5959

6060
/** Nested orchestrator surface (greybeard / package filter): dispatch only. */
61-
export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, "search_agents", "task"] as const;
61+
export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, "task"] as const;
6262

63-
/** Skywalker primary: orchestrator surface plus product writes for DIY tiny work. */
63+
/** Skywalker primary: orchestrator surface plus fleet discovery and product writes for DIY tiny work. */
6464
export const SKYWALKER_TOOLS = [
6565
...ORCHESTRATOR_TOOLS,
66+
"search_agents",
6667
"write_file",
6768
"edit_file",
6869
"delete_file",
6970
] as const;
71+

src/subagent/authority.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,26 @@ describe("assertTierMayMountFleetVerb", () => {
2525
expect(() => assertTierMayMountFleetVerb("leaf", "read_file")).not.toThrow();
2626
});
2727

28-
test("Tier 1 and Tier 2 may mount fleet verbs", () => {
28+
test("Tier 1 and Tier 2 may mount spawn/control fleet verbs", () => {
2929
expect(() => assertTierMayMountFleetVerb("orchestrator", "task")).not.toThrow();
3030
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "task")).not.toThrow();
31+
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "spawn_agent")).not.toThrow();
32+
});
33+
34+
// CL-7051: fleet discovery is Skywalker (Tier 1) only — nested directors keep
35+
// task/spawn allowlists but must not discover the full fleet.
36+
test("Tier 2 nested orchestrator cannot mount search_agents or list_agents", () => {
37+
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "search_agents")).toThrow(
38+
FleetAuthorityError,
39+
);
40+
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "list_agents")).toThrow(
41+
FleetAuthorityError,
42+
);
43+
});
44+
45+
test("Tier 1 orchestrator may mount search_agents and list_agents", () => {
46+
expect(() => assertTierMayMountFleetVerb("orchestrator", "search_agents")).not.toThrow();
47+
expect(() => assertTierMayMountFleetVerb("orchestrator", "list_agents")).not.toThrow();
3148
});
3249

3350
test("isFleetVerb matches the same set used for the gate", () => {
@@ -36,6 +53,7 @@ describe("assertTierMayMountFleetVerb", () => {
3653
});
3754
});
3855

56+
3957
describe("assertCanTargetAgent", () => {
4058
// Tree: skywalker(root) -> greybeard -> intern
4159
// -> build (sibling of greybeard)

src/subagent/authority.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
* (today: task, search_agents, read_agent_trace; the spawn_agent/
99
* wait_agents/list_agents/send_input/interrupt_agent/close_agent/
1010
* resume_agent/followup_task verbs land in later child issues against
11-
* this same gate).
11+
* this same gate). Fleet *discovery* verbs (search_agents, list_agents)
12+
* are further restricted to Tier 1 only (CL-7051) — nested orchestrators
13+
* keep task/spawn allowlists but must not discover the full fleet.
1214
* - assertCanTargetAgent: a Tier 2 nested orchestrator may act only on its
1315
* own descendants, never a sibling or anything above it in the tree.
1416
* Tier 1 (the primary orchestrator) may target anyone. Callers pass the
@@ -41,10 +43,20 @@ export const FLEET_VERBS = new Set([
4143
"followup_task",
4244
]);
4345

46+
/**
47+
* Fleet discovery — Tier 1 (skywalker) only. Nested orchestrators spawn from
48+
* a closed allowlist and must not index the full fleet (CL-7051).
49+
*/
50+
export const ORCHESTRATOR_ONLY_FLEET_VERBS = new Set(["search_agents", "list_agents"]);
51+
4452
export function isFleetVerb(toolName: string): boolean {
4553
return FLEET_VERBS.has(toolName);
4654
}
4755

56+
export function isOrchestratorOnlyFleetVerb(toolName: string): boolean {
57+
return ORCHESTRATOR_ONLY_FLEET_VERBS.has(toolName);
58+
}
59+
4860
export class FleetAuthorityError extends Error {
4961
constructor(message: string) {
5062
super(message);
@@ -53,17 +65,25 @@ export class FleetAuthorityError extends Error {
5365
}
5466

5567
/**
56-
* Guard at the tool-mount point: throws if a Tier 3 leaf is about to receive
57-
* a fleet verb. Call this where tools are assembled (run.ts), not from a
58-
* prompt instruction — a leaf must never even hold the tool.
68+
* Guard at the tool-mount point: throws if the caller's tier may not receive
69+
* this fleet verb. Call this where tools are assembled (run.ts), not from a
70+
* prompt instruction — a leaf must never even hold the tool; a nested
71+
* orchestrator must never hold fleet-discovery verbs.
5972
*/
6073
export function assertTierMayMountFleetVerb(tier: SubagentTier, toolName: string): void {
61-
if (tier === "leaf" && isFleetVerb(toolName)) {
74+
if (!isFleetVerb(toolName)) return;
75+
if (tier === "leaf") {
6276
throw new FleetAuthorityError(
6377
`Tier 3 leaf directors cannot mount fleet verb "${toolName}". ` +
6478
`Leaves get ask_director / submit_result / progress_note only.`,
6579
);
6680
}
81+
if (tier === "nested-orchestrator" && isOrchestratorOnlyFleetVerb(toolName)) {
82+
throw new FleetAuthorityError(
83+
`Tier 2 nested orchestrators cannot mount fleet discovery verb "${toolName}". ` +
84+
`Only Tier 1 (skywalker) may discover the fleet; nested directors spawn from their allowlist.`,
85+
);
86+
}
6787
}
6888

6989
/** Minimal shape of a live fleet member — matches SubAgentSessionStore records. */

src/subagent/run-authority.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
1111
import { mkdtemp } from "node:fs/promises";
1212
import { join } from "node:path";
1313

14+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
1415
import { createPermissionGate } from "../permission/gate.js";
1516
import { FleetAuthorityError } from "./authority.js";
1617
import { runSubAgent } from "./run.js";
@@ -81,3 +82,88 @@ describe("runSubAgent fleet-verb mount gate (CL-6941, fails closed)", () => {
8182
}
8283
});
8384
});
85+
86+
describe("runSubAgent search_agents mount gate (CL-7051, Tier-1 only)", () => {
87+
test("nested-orchestrator does not mount search_agents even when profiles exist", async () => {
88+
const cwd = await tmpCwd();
89+
let searchAgentsMounts = 0;
90+
91+
await withMockedModuleDuring(
92+
import.meta.resolve("../agent/agent-search.js"),
93+
(real: typeof import("../agent/agent-search.js")) => ({
94+
...real,
95+
createSearchAgentsTool: (getProfiles: () => never) => {
96+
searchAgentsMounts++;
97+
return real.createSearchAgentsTool(getProfiles);
98+
},
99+
}),
100+
async () => {
101+
// Re-import so the mock is visible to runSubAgent's binding.
102+
const { runSubAgent: run } = await import("./run.js");
103+
try {
104+
await run({
105+
...baseParams(cwd, join(cwd, ".ctx")),
106+
id: "greybeard-session",
107+
orchestrator: true,
108+
orchestratorTier: "nested-orchestrator",
109+
nestedDispatch: {
110+
permissionGate: testPermissionGate,
111+
getWorkdirBase: () => join(cwd, ".ctx"),
112+
provider: {
113+
providerName: "test",
114+
baseURL: "http://localhost",
115+
model: "test-model",
116+
},
117+
profiles: [{ id: "intern", systemPromptRole: "You are intern." }],
118+
},
119+
});
120+
} catch {
121+
// Inference/agent construction may fail; mount decisions run first.
122+
}
123+
},
124+
);
125+
126+
expect(searchAgentsMounts).toBe(0);
127+
});
128+
129+
test("Tier-1 orchestrator mounts search_agents when profiles exist", async () => {
130+
const cwd = await tmpCwd();
131+
let searchAgentsMounts = 0;
132+
133+
await withMockedModuleDuring(
134+
import.meta.resolve("../agent/agent-search.js"),
135+
(real: typeof import("../agent/agent-search.js")) => ({
136+
...real,
137+
createSearchAgentsTool: (getProfiles: () => never) => {
138+
searchAgentsMounts++;
139+
return real.createSearchAgentsTool(getProfiles);
140+
},
141+
}),
142+
async () => {
143+
const { runSubAgent: run } = await import("./run.js");
144+
try {
145+
await run({
146+
...baseParams(cwd, join(cwd, ".ctx")),
147+
id: "skywalker-session",
148+
orchestrator: true,
149+
orchestratorTier: "orchestrator",
150+
nestedDispatch: {
151+
permissionGate: testPermissionGate,
152+
getWorkdirBase: () => join(cwd, ".ctx"),
153+
provider: {
154+
providerName: "test",
155+
baseURL: "http://localhost",
156+
model: "test-model",
157+
},
158+
profiles: [{ id: "intern", systemPromptRole: "You are intern." }],
159+
},
160+
});
161+
} catch {
162+
// Inference/agent construction may fail; mount decisions run first.
163+
}
164+
},
165+
);
166+
167+
expect(searchAgentsMounts).toBe(1);
168+
});
169+
});

src/subagent/run.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -466,18 +466,20 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
466466
];
467467
}
468468

469-
// Orchestrators need task + search_agents installed, not just mentioned in
470-
// the prompt. Nested dispatch always forbids further orchestration so the
471-
// tree bottoms out after one hop.
469+
// Orchestrators need task installed, not just mentioned in the prompt.
470+
// Nested dispatch always forbids further orchestration so the tree
471+
// bottoms out after one hop. Fleet discovery (search_agents) is Tier-1
472+
// only (CL-7051) — nested directors keep task/spawn allowlists.
472473
if (params.orchestrator === true) {
473474
// Tier enforcement at the mount point, not the prompt, fails closed:
474475
// an unresolved tier defaults to "leaf" rather than skipping the check,
475476
// so an AgentProfile outside the closed director set cannot mount
476477
// task/search_agents just by setting orchestrator: true.
477478
const tier = params.orchestratorTier ?? "leaf";
479+
const mayDiscoverFleet = tier === "orchestrator";
478480
for (const verb of [
479481
"task",
480-
"search_agents",
482+
...(mayDiscoverFleet ? (["search_agents"] as const) : []),
481483
"read_agent_trace",
482484
"spawn_agent",
483485
"wait_agents",
@@ -523,7 +525,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
523525
...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}),
524526
...(nd.spawnAllowlist !== undefined ? { spawnAllowlist: nd.spawnAllowlist } : {}),
525527
}),
526-
...(nd.profiles !== undefined
528+
...(mayDiscoverFleet && nd.profiles !== undefined
527529
? [
528530
createSearchAgentsTool(() => {
529531
const profiles = nd.profiles;

0 commit comments

Comments
 (0)