Skip to content

Commit 77838ef

Browse files
Merge pull request #1010 from corbitsdev/cl-6822-exec-director-tool-allowlist-is-advisory-tool_search-re
Enforce exec director tool allowlists through tool_search and promote
2 parents 05f390d + 3af2578 commit 77838ef

7 files changed

Lines changed: 257 additions & 22 deletions

File tree

‎src/agent/fleet-verbs-mount.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,48 @@ describe("primary fleet verb mount", () => {
5555
await toolset.dispose();
5656
});
5757

58+
test("createAgentToolset mounts tool_search by default", async () => {
59+
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
60+
const { createAgentToolset } = await import("./tools.js");
61+
const permissionGate = {
62+
check: async () => ({ allowed: true }),
63+
getSkipPermissions: () => false,
64+
} as never;
65+
66+
const toolset = await createAgentToolset({ cwd, permissionGate });
67+
try {
68+
const names = toolset.dynamicRunner
69+
.currentDefinitions()
70+
.map((d) => d.name);
71+
expect(names).toContain("tool_search");
72+
} finally {
73+
await toolset.dispose();
74+
}
75+
});
76+
77+
test("createAgentToolset omits tool_search when the allow list excludes it", async () => {
78+
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
79+
const { createAgentToolset } = await import("./tools.js");
80+
const permissionGate = {
81+
check: async () => ({ allowed: true }),
82+
getSkipPermissions: () => false,
83+
} as never;
84+
85+
const toolset = await createAgentToolset({
86+
cwd,
87+
permissionGate,
88+
toolSearchAllow: ["read_file"],
89+
});
90+
try {
91+
const names = toolset.dynamicRunner
92+
.currentDefinitions()
93+
.map((d) => d.name);
94+
expect(names).not.toContain("tool_search");
95+
} finally {
96+
await toolset.dispose();
97+
}
98+
});
99+
58100
test("createAgentToolset mounts wait_agents when mountWaitAgents is true (exec primary)", async () => {
59101
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
60102
const { createAgentToolset } = await import("./tools.js");

‎src/agent/tool-search.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ describe("createToolIndex", () => {
227227
test("returns nothing for an empty query", () => {
228228
expect(index.search(" ")).toEqual([]);
229229
});
230+
231+
test("with an allow list, never returns tools outside the allow list", () => {
232+
const allowed = createToolIndex(() => defs, [], ["present"]);
233+
expect(allowed.search("pages")).toContain("present");
234+
expect(allowed.search("linear")).not.toContain("mcp__linear__create_issue");
235+
});
230236
});
231237

232238
function call(

‎src/agent/tool-search.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ function tokenize(text: string): string[] {
237237
export function createToolIndex(
238238
getDefs: () => readonly ToolDefinition[],
239239
advertisedNames: readonly string[] = ADVERTISED_TOOL_NAMES,
240+
// Closed allow list (exec director overlays): when set, the index only
241+
// surfaces allowed tools so search cannot promote outside the allow.
242+
allow?: readonly string[] | undefined,
240243
): ToolIndex {
241244
const score = (
242245
def: ToolDefinition,
@@ -264,6 +267,7 @@ export function createToolIndex(
264267
if (queryTokens.length === 0) return [];
265268
return getDefs()
266269
.filter((def) => !advertisedNames.includes(def.name))
270+
.filter((def) => allow === undefined || allow.includes(def.name))
267271
.map((def) => ({
268272
name: def.name,
269273
score: score(def, queryTokens, rawQuery),

‎src/agent/tools.ts‎

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ import {
108108
createToolIndex,
109109
createToolSearchTool,
110110
TOOL_SEARCH_PENDING_WAIT_MS,
111+
toolSearchDefinition,
111112
} from "./tool-search.js";
112113
import { createSearchAgentsTool } from "./agent-search.js";
113114
import { createReadAgentTraceTool } from "../subagent/trace-tool.js";
@@ -266,6 +267,13 @@ export interface AgentToolsetArgs {
266267
* and collect worker reports from mailbox mail instead.
267268
*/
268269
mountWaitAgents?: boolean;
270+
/**
271+
* Closed allow list (exec director overlays). tool_search is mounted only
272+
* when the allow includes it, and the search index only surfaces allowed
273+
* tools so search cannot promote outside the allow. Omit for the product
274+
* default (tool_search mounted, index over the live registry).
275+
*/
276+
toolSearchAllow?: readonly string[];
269277
}
270278

271279
// Per-server connection state surfaced to the TUI.
@@ -722,21 +730,32 @@ export async function createAgentToolset(
722730
const toolIndex = createToolIndex(
723731
() => runnerHolder.current?.currentDefinitions() ?? [],
724732
advertisedBuiltIns,
733+
args.toolSearchAllow,
725734
);
726-
baseTools.push(
727-
createToolSearchTool({
728-
search: (query) => toolIndex.search(query),
729-
lookup: (name) =>
730-
runnerHolder.current?.currentDefinitions().find((d) => d.name === name),
731-
promote: (names) => promoter.promote(names),
732-
// Misses wait briefly for in-flight MCP handshakes (bounded, so hung
733-
// OAuth cannot hang the call) and re-search before answering. Reads the
734-
// connection map live — declared below, populated by the time any
735-
// search runs.
736-
awaitPendingConnections: (timeoutMs = TOOL_SEARCH_PENDING_WAIT_MS) =>
737-
awaitPendingMcpConnections(timeoutMs),
738-
}),
739-
);
735+
// Closed exec allow lists omit tool_search itself (leaf posture); when the
736+
// allow excludes it the tool is never mounted, so there is nothing to
737+
// search with and nothing the promoter can activate.
738+
if (
739+
args.toolSearchAllow === undefined ||
740+
args.toolSearchAllow.includes(toolSearchDefinition.name)
741+
) {
742+
baseTools.push(
743+
createToolSearchTool({
744+
search: (query) => toolIndex.search(query),
745+
lookup: (name) =>
746+
runnerHolder.current
747+
?.currentDefinitions()
748+
.find((d) => d.name === name),
749+
promote: (names) => promoter.promote(names),
750+
// Misses wait briefly for in-flight MCP handshakes (bounded, so hung
751+
// OAuth cannot hang the call) and re-search before answering. Reads the
752+
// connection map live — declared below, populated by the time any
753+
// search runs.
754+
awaitPendingConnections: (timeoutMs = TOOL_SEARCH_PENDING_WAIT_MS) =>
755+
awaitPendingMcpConnections(timeoutMs),
756+
}),
757+
);
758+
}
740759

741760
// Codex apply_patch mounts when isCodex; primary strips it so Corbits DIY
742761
// stays on write_file/edit_file/delete_file. Leaves keep it via BUILD/DOCS allowlists.

‎src/exec/runner.test.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
3+
import { createAdvertisedToolset } from "../session/assemble-runtime.js";
4+
import {
5+
createExecToolCallGate,
6+
createExecToolPromoter,
7+
isExecOverlayToolAllowed,
8+
resolveExecDirectorOverlay,
9+
resolveExecDirectorOverlayForPackage,
10+
} from "./runner.js";
11+
12+
const OUTSIDE_ALLOW = "mcp__linear__create_issue";
13+
14+
describe("exec director allowlist", () => {
15+
test("explorer overlay narrows advertised tools to the package allow list", () => {
16+
const overlay = resolveExecDirectorOverlay("explorer");
17+
expect(overlay.advertisedAllow).toBeDefined();
18+
expect(overlay.advertisedAllow).toContain("read_file");
19+
expect(overlay.advertisedAllow).toContain("run_shell");
20+
expect(overlay.advertisedAllow).not.toContain("tool_search");
21+
expect(overlay.advertisedAllow).not.toContain(OUTSIDE_ALLOW);
22+
});
23+
24+
test("critic overlay narrows advertised tools to the package allow list", () => {
25+
const overlay = resolveExecDirectorOverlay("critic");
26+
expect(overlay.advertisedAllow).toBeDefined();
27+
expect(overlay.advertisedAllow).not.toContain("tool_search");
28+
expect(overlay.advertisedAllow).not.toContain(OUTSIDE_ALLOW);
29+
});
30+
31+
test("skywalker keeps the product default — no allow list", () => {
32+
const overlay = resolveExecDirectorOverlay("skywalker");
33+
expect(overlay.advertisedAllow).toBeUndefined();
34+
expect(overlay.mountFleet).toBe(true);
35+
});
36+
37+
test("deny entries are subtracted from the allow list", () => {
38+
const pkg = {
39+
...DIRECTOR_REGISTRY.explorer,
40+
tools: { allow: ["read_file", "run_shell"], deny: ["run_shell"] },
41+
};
42+
expect(resolveExecDirectorOverlayForPackage(pkg).advertisedAllow).toEqual([
43+
"read_file",
44+
]);
45+
});
46+
47+
test("an allow that deny empties is rejected loudly", () => {
48+
const pkg = {
49+
...DIRECTOR_REGISTRY.explorer,
50+
tools: { allow: ["run_shell"], deny: ["run_shell"] },
51+
};
52+
expect(() => resolveExecDirectorOverlayForPackage(pkg)).toThrow(/empty/);
53+
});
54+
55+
test("a deny-only package config is rejected loudly", () => {
56+
const pkg = {
57+
...DIRECTOR_REGISTRY.explorer,
58+
tools: { deny: ["run_shell"] },
59+
};
60+
expect(() => resolveExecDirectorOverlayForPackage(pkg)).toThrow(/deny/);
61+
});
62+
63+
test("promote cannot make an outside-allow tool callable under explorer", () => {
64+
const overlay = resolveExecDirectorOverlay("explorer");
65+
expect(isExecOverlayToolAllowed(overlay, OUTSIDE_ALLOW)).toBe(false);
66+
const { activated, computeAdvertised, isAdvertised } =
67+
createAdvertisedToolset({
68+
sessionMode: "orchestrator",
69+
toolAvailability: { languageServerAvailable: true },
70+
getProvider: () => ({ providerName: "test", model: "test-model" }),
71+
builtInPrefix: overlay.advertisedAllow,
72+
});
73+
const promote = createExecToolPromoter({
74+
activate: (names) => activated.activate(names),
75+
isAllowed: (name) => isExecOverlayToolAllowed(overlay, name),
76+
currentDefinitions: () => [],
77+
computeAdvertised,
78+
updateDirectorTools: () => undefined,
79+
});
80+
promote([OUTSIDE_ALLOW]);
81+
expect(activated.has(OUTSIDE_ALLOW)).toBe(false);
82+
expect(isAdvertised(OUTSIDE_ALLOW)).toBe(false);
83+
expect(
84+
createExecToolCallGate(isAdvertised, { isCodex: false })(OUTSIDE_ALLOW),
85+
).toBe(false);
86+
});
87+
88+
test("the promoter gates allow itself — a raw activate caller gets no bypass", () => {
89+
const overlay = resolveExecDirectorOverlay("explorer");
90+
const { activated, computeAdvertised } = createAdvertisedToolset({
91+
sessionMode: "orchestrator",
92+
toolAvailability: { languageServerAvailable: true },
93+
getProvider: () => ({ providerName: "test", model: "test-model" }),
94+
builtInPrefix: overlay.advertisedAllow,
95+
});
96+
let advertisedCount = 0;
97+
const promote = createExecToolPromoter({
98+
activate: (names) => activated.activate(names),
99+
isAllowed: (name) => isExecOverlayToolAllowed(overlay, name),
100+
currentDefinitions: () => [],
101+
computeAdvertised,
102+
updateDirectorTools: () => {
103+
advertisedCount += 1;
104+
},
105+
});
106+
promote([OUTSIDE_ALLOW, "read_file"]);
107+
expect(activated.has(OUTSIDE_ALLOW)).toBe(false);
108+
expect(activated.has("read_file")).toBe(true);
109+
expect(advertisedCount).toBe(1);
110+
});
111+
112+
test("skywalker overlay leaves every tool allowed", () => {
113+
const overlay = resolveExecDirectorOverlay("skywalker");
114+
expect(isExecOverlayToolAllowed(overlay, OUTSIDE_ALLOW)).toBe(true);
115+
});
116+
});

‎src/exec/runner.ts‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { xaiProfileFromProviderName } from "../config/xai-providers.js";
1717
import { formatDirectorSystemPrompt } from "../agent/directors/identity.js";
1818
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
19-
import type { DirectorId } from "../agent/directors/types.js";
19+
import type { DirectorId, DirectorPackage } from "../agent/directors/types.js";
2020
import { submitOutputDefinition } from "../agent/director.js";
2121
import {
2222
shellDefinition,
@@ -265,19 +265,58 @@ export function resolveExecDirectorOverlay(
265265
if (director === undefined || director === "skywalker") {
266266
return { mountFleet: true };
267267
}
268-
const pkg = DIRECTOR_REGISTRY[director];
268+
return resolveExecDirectorOverlayForPackage(DIRECTOR_REGISTRY[director]);
269+
}
270+
271+
/**
272+
* Single enforcement point for the exec allowlist. Everything the overlay
273+
* permits — tool_search results, promoter activation, the call gate — flows
274+
* through here, so a tool outside the allow can never become callable.
275+
*/
276+
export function isExecOverlayToolAllowed(
277+
overlay: ExecDirectorOverlay,
278+
name: string,
279+
): boolean {
280+
return (
281+
overlay.advertisedAllow === undefined ||
282+
overlay.advertisedAllow.includes(name)
283+
);
284+
}
285+
286+
export function resolveExecDirectorOverlayForPackage(
287+
pkg: DirectorPackage,
288+
): ExecDirectorOverlay {
269289
const allow = pkg.tools?.allow;
270-
const advertisedAllow =
290+
const deny = pkg.tools?.deny ?? [];
291+
if ((allow === undefined || allow.length === 0) && deny.length > 0) {
292+
throw new Error(
293+
`Director package "${pkg.id}" sets tools.deny without tools.allow — ` +
294+
"exec overlays enforce a closed allow list, so a deny-only package " +
295+
"has no list to subtract from. Add tools.allow.",
296+
);
297+
}
298+
const allowed =
271299
allow !== undefined && allow.length > 0
300+
? allow.filter((name) => !deny.includes(name))
301+
: undefined;
302+
if (allowed !== undefined && allowed.length === 0) {
303+
throw new Error(
304+
`Director package "${pkg.id}" tools.allow minus tools.deny is empty — ` +
305+
"exec overlays enforce a closed allow list, so no tool would be " +
306+
"advertised. Keep an allow entry outside tools.deny.",
307+
);
308+
}
309+
const advertisedAllow =
310+
allowed !== undefined
272311
? pkg.spawn.maySpawn
273312
? [
274-
...allow,
313+
...allowed,
275314
// Exec mounts wait_agents beside the fleet verbs (mountWaitAgents),
276315
// so it stays advertised here even though the package allow omits
277316
// it for TUI/nested mailbox-mail collection.
278-
...(!allow.includes("wait_agents") ? ["wait_agents"] : []),
317+
...(!allowed.includes("wait_agents") ? ["wait_agents"] : []),
279318
]
280-
: allow.filter(
319+
: allowed.filter(
281320
(name) =>
282321
![
283322
"search_agents",
@@ -359,13 +398,14 @@ export function createExecToolCallGate(
359398

360399
export function createExecToolPromoter(args: {
361400
activate: (names: readonly string[]) => boolean;
401+
isAllowed: (name: string) => boolean;
362402
currentDefinitions: () => readonly ToolDefinition[];
363403
computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[];
364404
updateDirectorTools: (defs: ToolDefinition[]) => void;
365405
persist?: () => void;
366406
}): (names: string[]) => void {
367407
return (names) => {
368-
if (!args.activate(names)) return;
408+
if (!args.activate(names.filter((name) => args.isAllowed(name)))) return;
369409
args.updateDirectorTools(args.computeAdvertised(args.currentDefinitions()));
370410
args.persist?.();
371411
};
@@ -649,6 +689,11 @@ export async function runExec(config: Config): Promise<ExecResult> {
649689
: {}),
650690
sessionMode,
651691
toolAvailability,
692+
// Closed director overlays unmount tool_search (unless allowed) and
693+
// filter its index, so search cannot surface outside-allow tools.
694+
...(overlay.advertisedAllow !== undefined
695+
? { toolSearchAllow: overlay.advertisedAllow }
696+
: {}),
652697
// Exec-primary keeps wait_agents mounted (with an advertised allow):
653698
// headless runs have no mailbox-mail flush, so wait_agents stays the
654699
// collection path here. TUI primary and nested orchestrators omit it.
@@ -839,10 +884,12 @@ export async function runExec(config: Config): Promise<ExecResult> {
839884

840885
// tool_search starts as a no-op promoter; without this, the call gate
841886
// refuses MCP/present/plugin names the result just told the model to
842-
// invoke.
887+
// invoke. Under a closed overlay the promoter only activates allowed
888+
// names, so outside-allow tools can never become advertised or callable.
843889
agentToolset.setToolPromoter(
844890
createExecToolPromoter({
845891
activate: (names) => activatedToolNames.activate(names),
892+
isAllowed: (name) => isExecOverlayToolAllowed(overlay, name),
846893
currentDefinitions: () =>
847894
agentToolset.dynamicRunner.currentDefinitions(),
848895
computeAdvertised,

‎tests/unit/exec/runner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@ describe("exec tool call gate and promoter", () => {
784784
const directorNames: string[][] = [];
785785
const promote = createExecToolPromoter({
786786
activate: (names) => activated.activate(names),
787+
isAllowed: () => true,
787788
currentDefinitions: () => runner.currentDefinitions(),
788789
computeAdvertised,
789790
updateDirectorTools: (defs) => {

0 commit comments

Comments
 (0)