Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/agent/directors/greybeard/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ 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");
Expand Down
6 changes: 6 additions & 0 deletions src/agent/directors/tool-sets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
11 changes: 3 additions & 8 deletions src/agent/directors/tool-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +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,
"search_agents",
"task",
] as const;
export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS, "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;
19 changes: 18 additions & 1 deletion src/subagent/authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
30 changes: 25 additions & 5 deletions src/subagent/authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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. */
Expand Down
86 changes: 86 additions & 0 deletions src/subagent/run-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
12 changes: 7 additions & 5 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,18 +466,20 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
];
}

// Orchestrators need task + search_agents installed, not just mentioned in
// the prompt. Nested dispatch always forbids further orchestration so the
// tree bottoms out after one hop.
// Orchestrators need task installed, not just mentioned in the prompt.
// Nested dispatch always forbids further orchestration so the tree
// bottoms out after one hop. Fleet discovery (search_agents) is Tier-1
// only (CL-7051) — nested directors keep task/spawn allowlists.
if (params.orchestrator === true) {
// Tier enforcement at the mount point, not the prompt, fails closed:
// an unresolved tier defaults to "leaf" rather than skipping the check,
// so an AgentProfile outside the closed director set cannot mount
// task/search_agents just by setting orchestrator: true.
const tier = params.orchestratorTier ?? "leaf";
const mayDiscoverFleet = tier === "orchestrator";
for (const verb of [
"task",
"search_agents",
...(mayDiscoverFleet ? (["search_agents"] as const) : []),
"read_agent_trace",
"spawn_agent",
"wait_agents",
Expand Down Expand Up @@ -523,7 +525,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}),
...(nd.spawnAllowlist !== undefined ? { spawnAllowlist: nd.spawnAllowlist } : {}),
}),
...(nd.profiles !== undefined
...(mayDiscoverFleet && nd.profiles !== undefined
? [
createSearchAgentsTool(() => {
const profiles = nd.profiles;
Expand Down
Loading