Skip to content

Commit 3d5b31d

Browse files
committed
fix(exec): enforce director allowlists through tool_search and promote
1 parent d2a789e commit 3d5b31d

3 files changed

Lines changed: 84 additions & 17 deletions

File tree

‎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: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ import {
103103
} from "../tools/web-search.js";
104104
import { createUseSkillTool } from "./use-skill.js";
105105
import { createSkillSearchTool } from "./skill-search.js";
106-
import { createToolIndex, createToolSearchTool } from "./tool-search.js";
106+
import {
107+
createToolIndex,
108+
createToolSearchTool,
109+
toolSearchDefinition,
110+
} from "./tool-search.js";
107111
import { createSearchAgentsTool } from "./agent-search.js";
108112
import { createReadAgentTraceTool } from "../subagent/trace-tool.js";
109113
import {
@@ -261,6 +265,13 @@ export interface AgentToolsetArgs {
261265
* and collect worker reports from mailbox mail instead.
262266
*/
263267
mountWaitAgents?: boolean;
268+
/**
269+
* Closed allow list (exec director overlays). tool_search is mounted only
270+
* when the allow includes it, and the search index only surfaces allowed
271+
* tools so search cannot promote outside the allow. Omit for the product
272+
* default (tool_search mounted, index over the live registry).
273+
*/
274+
toolSearchAllow?: readonly string[];
264275
}
265276

266277
// Per-server connection state surfaced to the TUI.
@@ -712,15 +723,26 @@ export async function createAgentToolset(
712723
const toolIndex = createToolIndex(
713724
() => runnerHolder.current?.currentDefinitions() ?? [],
714725
advertisedBuiltIns,
726+
args.toolSearchAllow,
715727
);
716-
baseTools.push(
717-
createToolSearchTool({
718-
search: (query) => toolIndex.search(query),
719-
lookup: (name) =>
720-
runnerHolder.current?.currentDefinitions().find((d) => d.name === name),
721-
promote: (names) => promoter.promote(names),
722-
}),
723-
);
728+
// Closed exec allow lists omit tool_search itself (leaf posture); when the
729+
// allow excludes it the tool is never mounted, so there is nothing to
730+
// search with and nothing the promoter can activate.
731+
if (
732+
args.toolSearchAllow === undefined ||
733+
args.toolSearchAllow.includes(toolSearchDefinition.name)
734+
) {
735+
baseTools.push(
736+
createToolSearchTool({
737+
search: (query) => toolIndex.search(query),
738+
lookup: (name) =>
739+
runnerHolder.current
740+
?.currentDefinitions()
741+
.find((d) => d.name === name),
742+
promote: (names) => promoter.promote(names),
743+
}),
744+
);
745+
}
724746

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

‎src/exec/runner.ts‎

Lines changed: 49 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,51 @@ 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+
const advertisedAllow =
303+
allowed !== undefined
272304
? pkg.spawn.maySpawn
273305
? [
274-
...allow,
306+
...allowed,
275307
// Exec mounts wait_agents beside the fleet verbs (mountWaitAgents),
276308
// so it stays advertised here even though the package allow omits
277309
// it for TUI/nested mailbox-mail collection.
278-
...(!allow.includes("wait_agents") ? ["wait_agents"] : []),
310+
...(!allowed.includes("wait_agents") ? ["wait_agents"] : []),
279311
]
280-
: allow.filter(
312+
: allowed.filter(
281313
(name) =>
282314
![
283315
"search_agents",
@@ -649,6 +681,11 @@ export async function runExec(config: Config): Promise<ExecResult> {
649681
: {}),
650682
sessionMode,
651683
toolAvailability,
684+
// Closed director overlays unmount tool_search (unless allowed) and
685+
// filter its index, so search cannot surface outside-allow tools.
686+
...(overlay.advertisedAllow !== undefined
687+
? { toolSearchAllow: overlay.advertisedAllow }
688+
: {}),
652689
// Exec-primary keeps wait_agents mounted (with an advertised allow):
653690
// headless runs have no mailbox-mail flush, so wait_agents stays the
654691
// collection path here. TUI primary and nested orchestrators omit it.
@@ -839,10 +876,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
839876

840877
// tool_search starts as a no-op promoter; without this, the call gate
841878
// refuses MCP/present/plugin names the result just told the model to
842-
// invoke.
879+
// invoke. Under a closed overlay the promoter only activates allowed
880+
// names, so outside-allow tools can never become advertised or callable.
843881
agentToolset.setToolPromoter(
844882
createExecToolPromoter({
845-
activate: (names) => activatedToolNames.activate(names),
883+
activate: (names) =>
884+
activatedToolNames.activate(
885+
names.filter((name) => isExecOverlayToolAllowed(overlay, name)),
886+
),
846887
currentDefinitions: () =>
847888
agentToolset.dynamicRunner.currentDefinitions(),
848889
computeAdvertised,

0 commit comments

Comments
 (0)