diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 72b4530db47..8985017ca1b 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -92,6 +92,7 @@ import { } from "./tool-description-presets.js"; import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js"; import { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js"; +import { buildDeclaredToolAllowlistContext } from "./tool-policy-declared-context.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -1183,6 +1184,11 @@ export function createOpenClawCodingTools(options?: { { policy: inheritedToolPolicy, label: "inherited tools", unavailableCoreToolReason }, ], auditLogLevel: options?.toolPolicyAuditLogLevel, + declaredToolAllowlist: buildDeclaredToolAllowlistContext({ + config: options?.config, + workspaceDir: workspaceRoot, + toolDenylist: pluginToolDenylist, + }), }); if (shouldInheritEffectiveToolAllowlist) { replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, subagentFiltered); diff --git a/src/agents/embedded-agent-runner/effective-tool-policy.ts b/src/agents/embedded-agent-runner/effective-tool-policy.ts index 78e3fec4f18..4c5fd87b1a5 100644 --- a/src/agents/embedded-agent-runner/effective-tool-policy.ts +++ b/src/agents/embedded-agent-runner/effective-tool-policy.ts @@ -15,12 +15,17 @@ import { isSubagentEnvelopeSession, resolveSubagentCapabilityStore, } from "../subagent-capabilities.js"; +import { buildDeclaredToolAllowlistContext } from "../tool-policy-declared-context.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, type ToolPolicyPipelineStep, } from "../tool-policy-pipeline.js"; -import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../tool-policy.js"; +import { + collectExplicitDenylist, + mergeAlsoAllowPolicy, + resolveToolProfilePolicy, +} from "../tool-policy.js"; import type { AnyAgentTool } from "../tools/common.js"; /** @@ -178,5 +183,9 @@ export function applyFinalEffectiveToolPolicy( warn: params.warn, steps: pipelineSteps, auditLogLevel: params.toolPolicyAuditLogLevel, + declaredToolAllowlist: buildDeclaredToolAllowlistContext({ + config: params.config, + toolDenylist: collectExplicitDenylist(pipelineSteps.map((step) => step.policy)), + }), }); } diff --git a/src/agents/tool-policy-declared-context.ts b/src/agents/tool-policy-declared-context.ts new file mode 100644 index 00000000000..9f7648ca65f --- /dev/null +++ b/src/agents/tool-policy-declared-context.ts @@ -0,0 +1,196 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; +import { + isManifestPluginAvailableForControlPlane, + loadManifestMetadataSnapshot, +} from "../plugins/manifest-contract-eligibility.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import { hasManifestToolAvailability } from "../plugins/manifest-tool-availability.js"; +import { sanitizeServerName, TOOL_NAME_SEPARATOR } from "./agent-bundle-mcp-names.js"; +import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; +import type { DeclaredToolAllowlistContext } from "./tool-policy.js"; +import { normalizeToolName } from "./tool-policy.js"; + +type ToolDenylist = ReturnType; + +function normalizeToolDenylist(list?: string[]): ToolDenylist { + return compileGlobPatterns({ raw: list, normalize: normalizeToolName }); +} + +function denylistBlocksName(name: string, denylist: ToolDenylist): boolean { + const normalized = normalizeToolName(name); + return normalized ? matchesAnyGlobPattern(normalized, denylist) : false; +} + +function denylistBlocksMcpServerNamespace(params: { + safeServerName: string; + denylist: ToolDenylist; +}): boolean { + const serverPrefix = normalizeToolName(params.safeServerName + TOOL_NAME_SEPARATOR); + if (!serverPrefix) { + return false; + } + return matchesAnyGlobPattern(serverPrefix, params.denylist); +} + +function denylistBlocksMcpServer(params: { + safeServerName: string; + denylist: ToolDenylist; +}): boolean { + return ( + denylistBlocksName("bundle-mcp", params.denylist) || + matchesAnyGlobPattern("group:plugins", params.denylist) || + denylistBlocksMcpServerNamespace({ + safeServerName: params.safeServerName, + denylist: params.denylist, + }) + ); +} + +function denylistBlocksPlugin(params: { pluginId: string; denylist: ToolDenylist }): boolean { + return ( + denylistBlocksName(params.pluginId, params.denylist) || + matchesAnyGlobPattern("group:plugins", params.denylist) + ); +} + +function denylistBlocksPluginTool(params: { + pluginId: string; + toolName: string; + denylist: ToolDenylist; +}): boolean { + return ( + denylistBlocksPlugin({ pluginId: params.pluginId, denylist: params.denylist }) || + denylistBlocksName(params.toolName, params.denylist) + ); +} + +function collectConfiguredMcpServerNames(params: { + config?: OpenClawConfig; + toolDenylist?: string[]; +}): string[] { + const servers = normalizeConfiguredMcpServers(params.config?.mcp?.servers); + const denylist = normalizeToolDenylist(params.toolDenylist); + const usedServerNames = new Set(); + const names: string[] = []; + for (const [name, value] of Object.entries(servers)) { + if (!isRecord(value) || value.enabled === false || !name.trim()) { + continue; + } + const safeServerName = sanitizeServerName(name, usedServerNames); + if ( + denylistBlocksMcpServer({ + safeServerName, + denylist, + }) + ) { + continue; + } + names.push(safeServerName); + } + return names; +} + +function collectAvailableManifestToolNames(params: { + plugin: PluginManifestRecord; + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; + denylist: ToolDenylist; +}): string[] { + return (params.plugin.contracts?.tools ?? []) + .filter( + (toolName) => + !denylistBlocksPluginTool({ + pluginId: params.plugin.id, + toolName, + denylist: params.denylist, + }), + ) + .filter((toolName) => + hasManifestToolAvailability({ + plugin: params.plugin, + toolNames: [toolName], + config: params.config, + env: params.env, + }), + ) + .map(normalizeToolName) + .filter(Boolean); +} + +function collectDeclaredPluginContext(params: { + config?: OpenClawConfig; + workspaceDir?: string; + toolDenylist?: string[]; + env?: NodeJS.ProcessEnv; +}): Pick { + if (params.config?.plugins?.enabled === false) { + return {}; + } + const env = params.env ?? process.env; + const snapshot = loadManifestMetadataSnapshot({ + config: params.config, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + env, + }); + const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); + const denylist = normalizeToolDenylist(params.toolDenylist); + const pluginIds = new Set(); + const pluginToolNames = new Set(); + for (const plugin of snapshot.manifestRegistry.plugins) { + if ( + !isManifestPluginAvailableForControlPlane({ + snapshot, + plugin, + config: params.config, + }) || + normalizedPlugins.entries[plugin.id]?.enabled === false || + normalizedPlugins.deny.includes(plugin.id) || + denylistBlocksPlugin({ pluginId: plugin.id, denylist }) + ) { + continue; + } + const availableToolNames = collectAvailableManifestToolNames({ + plugin, + config: params.config, + env, + denylist, + }); + if (availableToolNames.length === 0) { + continue; + } + pluginIds.add(plugin.id); + for (const toolName of availableToolNames) { + pluginToolNames.add(toolName); + } + } + return { pluginIds, pluginToolNames }; +} + +export function buildDeclaredToolAllowlistContext(params: { + config?: OpenClawConfig; + workspaceDir?: string; + toolDenylist?: string[]; + env?: NodeJS.ProcessEnv; +}): DeclaredToolAllowlistContext | undefined { + const mcpServerNames = uniqueStrings( + collectConfiguredMcpServerNames({ + config: params.config, + toolDenylist: params.toolDenylist, + }), + ); + const pluginContext = collectDeclaredPluginContext(params); + const pluginIds = uniqueStrings(pluginContext.pluginIds ?? []); + const pluginToolNames = uniqueStrings(pluginContext.pluginToolNames ?? []); + if (mcpServerNames.length === 0 && pluginIds.length === 0 && pluginToolNames.length === 0) { + return undefined; + } + return { + ...(pluginIds.length > 0 ? { pluginIds } : {}), + ...(pluginToolNames.length > 0 ? { pluginToolNames } : {}), + ...(mcpServerNames.length > 0 ? { mcpServerNames } : {}), + }; +} diff --git a/src/agents/tool-policy-pipeline.test.ts b/src/agents/tool-policy-pipeline.test.ts index a2e4372eeb0..497aa9a6acf 100644 --- a/src/agents/tool-policy-pipeline.test.ts +++ b/src/agents/tool-policy-pipeline.test.ts @@ -1,6 +1,7 @@ // Tool policy pipeline tests cover profile/allowlist filtering, diagnostics, // warning dedupe, and plugin-aware policy application. import { beforeEach, describe, expect, test, vi } from "vitest"; +import { buildDeclaredToolAllowlistContext } from "./tool-policy-declared-context.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -154,6 +155,308 @@ describe("tool-policy-pipeline", () => { expect(warnings).toStrictEqual([]); }); + test("does not warn for declared plugin tools that are not materialized yet", () => { + const warnings: string[] = []; + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: { pluginToolNames: ["llm-task"] }, + steps: [ + { + policy: { allow: ["llm-task"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toStrictEqual([]); + }); + + test("does not warn for declared MCP server namespace globs", () => { + const warnings: string[] = []; + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: { mcpServerNames: ["paperless", "Home Assistant"] }, + steps: [ + { + policy: { allow: ["paperless__*", "home-assistant__search"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toStrictEqual([]); + }); + + test("still warns for undeclared MCP namespace globs", () => { + const warnings: string[] = []; + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: { mcpServerNames: ["paperless"] }, + steps: [ + { + policy: { allow: ["papreless__*"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (papreless__*). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + + test("declared context excludes disabled plugin tools", () => { + const declared = buildDeclaredToolAllowlistContext({ + config: { plugins: { entries: { browser: { enabled: false } } } }, + workspaceDir: process.cwd(), + }); + + expect(Array.from(declared?.pluginToolNames ?? [])).not.toContain("browser"); + }); + + test("declared context excludes denied plugin tools", () => { + const declared = buildDeclaredToolAllowlistContext({ + config: { plugins: { entries: { browser: { enabled: true } } } }, + workspaceDir: process.cwd(), + toolDenylist: ["browser"], + }); + + expect(Array.from(declared?.pluginToolNames ?? [])).not.toContain("browser"); + }); + + test("declared context excludes disabled MCP servers", () => { + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { + servers: { + paperless: { command: "paperless-mcp" }, + disabled: { command: "disabled-mcp", enabled: false }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(Array.from(declared?.mcpServerNames ?? [])).toContain("paperless"); + expect(Array.from(declared?.mcpServerNames ?? [])).not.toContain("disabled"); + }); + + test("warns when disabled MCP server namespace is allowlisted", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { servers: { disabled: { command: "disabled-mcp", enabled: false } } }, + }, + workspaceDir: process.cwd(), + }); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["disabled__*"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (disabled__*). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + + test("warns when bundle MCP is denied and allowlisted", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { servers: { paperless: { command: "paperless-mcp" } } }, + }, + workspaceDir: process.cwd(), + toolDenylist: ["bundle-mcp"], + }); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["bundle-mcp"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (bundle-mcp). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + + test("warns when denied MCP server namespace is allowlisted", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { servers: { paperless: { command: "paperless-mcp" } } }, + }, + workspaceDir: process.cwd(), + toolDenylist: ["paperless__*"], + }); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["paperless__*"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (paperless__*). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + + test("warns when broad MCP server wildcard deny covers an allowlisted namespace", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { servers: { paperless: { command: "paperless-mcp" } } }, + }, + workspaceDir: process.cwd(), + toolDenylist: ["paperless*"], + }); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["paperless__*"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (paperless__*). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + + test("does not warn for MCP server namespace allowlist when one exact server tool is denied", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { servers: { paperless: { command: "paperless-mcp" } } }, + }, + workspaceDir: process.cwd(), + toolDenylist: ["paperless__delete"], + }); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["paperless__*"], deny: ["paperless__delete"] }, + label: "tools", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([]); + }); + + test("warns when plugin group is denied and MCP server namespace is allowlisted", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { servers: { paperless: { command: "paperless-mcp" } } }, + }, + workspaceDir: process.cwd(), + toolDenylist: ["group:plugins"], + }); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["paperless__*"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (paperless__*). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + + test("warns when denied duplicate-safe MCP server namespace is allowlisted", () => { + const warnings: string[] = []; + const declared = buildDeclaredToolAllowlistContext({ + config: { + mcp: { + servers: { + "vigil harbor": { command: "vigil-mcp" }, + "vigil:harbor": { command: "vigil-alt-mcp" }, + }, + }, + }, + workspaceDir: process.cwd(), + toolDenylist: ["vigil-harbor-2__*"], + }); + + expect(Array.from(declared?.mcpServerNames ?? [])).toEqual(["vigil-harbor"]); + + applyToolPolicyPipeline({ + tools: [{ name: "exec" }] as any, + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + declaredToolAllowlist: declared, + steps: [ + { + policy: { allow: ["vigil-harbor__*", "vigil-harbor-2__*"] }, + label: "tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }); + + expect(warnings).toEqual([ + "tools: tools.allow allowlist contains unknown entries (vigil-harbor-2__*). These entries won't match any tool unless the plugin is enabled.", + ]); + }); + test("dedupes identical unknown-allowlist warnings across repeated runs", () => { const warnings: string[] = []; const tools = [{ name: "exec" }] as unknown as DummyTool[]; diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index cb60c85cddf..17401331c78 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -12,6 +12,7 @@ import { buildPluginToolGroups, expandPolicyWithPluginGroups, normalizeToolName, + type DeclaredToolAllowlistContext, type ToolPolicyLike, } from "./tool-policy.js"; @@ -129,6 +130,7 @@ export function applyToolPolicyPipeline(params: { warn: (message: string) => void; steps: ToolPolicyPipelineStep[]; auditLogLevel?: ToolPolicyAuditLogLevel; + declaredToolAllowlist?: DeclaredToolAllowlistContext; }): AnyAgentTool[] { const coreToolNames = new Set( params.tools @@ -151,7 +153,12 @@ export function applyToolPolicyPipeline(params: { let policy: ToolPolicyLike | undefined = step.policy; if (step.stripPluginOnlyAllowlist) { // Plugin-only allowlists are valid for deferred tools; warn only for entries that cannot match. - const resolved = analyzeAllowlistByToolType(policy, pluginGroups, coreToolNames); + const resolved = analyzeAllowlistByToolType( + policy, + pluginGroups, + coreToolNames, + params.declaredToolAllowlist, + ); if (resolved.unknownAllowlist.length > 0) { const unavailableCoreWarningAllowlist = new Set( (step.suppressUnavailableCoreToolWarningAllowlist ?? []).map((entry) => diff --git a/src/agents/tool-policy.plugin-only-allowlist.test.ts b/src/agents/tool-policy.plugin-only-allowlist.test.ts index 1c96d54f18f..06dd2fa1abc 100644 --- a/src/agents/tool-policy.plugin-only-allowlist.test.ts +++ b/src/agents/tool-policy.plugin-only-allowlist.test.ts @@ -75,6 +75,40 @@ describe("analyzeAllowlistByToolType", () => { expect(policy.unknownAllowlist).toEqual(["apply_patch"]); }); + it("recognizes declared plugin tools before they are materialized", () => { + const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() }; + const policy = analyzeAllowlistByToolType({ allow: ["llm-task"] }, emptyPlugins, coreTools, { + pluginToolNames: ["llm-task"], + }); + expect(policy.policy?.allow).toEqual(["llm-task"]); + expect(policy.pluginOnlyAllowlist).toBe(true); + expect(policy.unknownAllowlist).toStrictEqual([]); + }); + + it("recognizes declared MCP server namespace allowlists before tools are materialized", () => { + const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() }; + const policy = analyzeAllowlistByToolType( + { allow: ["paperless__*", "home-assistant__search"] }, + emptyPlugins, + coreTools, + { mcpServerNames: ["paperless", "Home Assistant"] }, + ); + expect(policy.pluginOnlyAllowlist).toBe(true); + expect(policy.unknownAllowlist).toStrictEqual([]); + }); + + it("still reports undeclared MCP namespace allowlist typos", () => { + const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() }; + const policy = analyzeAllowlistByToolType( + { allow: ["papreless__*"] }, + emptyPlugins, + coreTools, + { mcpServerNames: ["paperless"] }, + ); + expect(policy.pluginOnlyAllowlist).toBe(false); + expect(policy.unknownAllowlist).toStrictEqual(["papreless__*"]); + }); + it("ignores empty plugin ids when building groups", () => { const groups = buildPluginToolGroups({ tools: [{ name: "lobster" }], diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index aea9ab9ed19..97902f915d0 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -5,6 +5,7 @@ */ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { sanitizeServerName, TOOL_NAME_SEPARATOR } from "./agent-bundle-mcp-names.js"; import { IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW } from "./sandbox-tool-policy.js"; import { expandToolGroups, normalizeToolList, normalizeToolName } from "./tool-policy-shared.js"; export { @@ -37,6 +38,12 @@ type AllowlistResolution = { pluginOnlyAllowlist: boolean; }; +export type DeclaredToolAllowlistContext = { + pluginToolNames?: Iterable; + pluginIds?: Iterable; + mcpServerNames?: Iterable; +}; + /** Synthetic allowlist entry that means "use default plugin tools". */ export const DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY = "__openclaw_default_plugin_tools__"; @@ -188,11 +195,56 @@ export function expandPolicyWithPluginGroups( }; } +function buildDeclaredMcpToolPrefixes(serverNames?: Iterable): Set { + const prefixes = new Set(); + const usedNames = new Set(); + for (const serverName of serverNames ?? []) { + const safeName = sanitizeServerName(serverName, usedNames); + const prefix = normalizeToolName(safeName + TOOL_NAME_SEPARATOR); + if (prefix) { + prefixes.add(prefix); + } + } + return prefixes; +} + +function normalizeDeclaredPluginIds(values?: Iterable): Set { + return new Set( + Array.from(values ?? [], (value) => normalizeOptionalLowercaseString(value)).filter( + (value): value is string => Boolean(value), + ), + ); +} + +function normalizeDeclaredToolNames(values?: Iterable): Set { + return new Set( + Array.from(values ?? [], (value) => normalizeToolName(value)).filter((value): value is string => + Boolean(value), + ), + ); +} + +function isDeclaredMcpAllowlistEntry(entry: string, prefixes: Set): boolean { + if (prefixes.size === 0) { + return false; + } + if (entry === "bundle-mcp") { + return true; + } + for (const prefix of prefixes) { + if (entry.length > prefix.length && entry.startsWith(prefix)) { + return true; + } + } + return false; +} + /** Classifies allowlists as core, plugin-only, or unknown for diagnostics. */ export function analyzeAllowlistByToolType( policy: ToolPolicyLike | undefined, groups: PluginToolGroups, coreTools: Set, + declaredTools?: DeclaredToolAllowlistContext, ): AllowlistResolution { if (!policy?.allow || policy.allow.length === 0) { return { policy, unknownAllowlist: [], pluginOnlyAllowlist: false }; @@ -201,8 +253,15 @@ export function analyzeAllowlistByToolType( if (normalized.length === 0) { return { policy, unknownAllowlist: [], pluginOnlyAllowlist: false }; } - const pluginIds = new Set(groups.byPlugin.keys()); - const pluginTools = new Set(groups.all); + const pluginIds = new Set([ + ...groups.byPlugin.keys(), + ...normalizeDeclaredPluginIds(declaredTools?.pluginIds), + ]); + const pluginTools = new Set([ + ...groups.all, + ...normalizeDeclaredToolNames(declaredTools?.pluginToolNames), + ]); + const mcpToolPrefixes = buildDeclaredMcpToolPrefixes(declaredTools?.mcpServerNames); const unknownAllowlist: string[] = []; let hasOnlyPluginEntries = true; for (const entry of normalized) { @@ -211,7 +270,10 @@ export function analyzeAllowlistByToolType( continue; } const isPluginEntry = - entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry); + entry === "group:plugins" || + pluginIds.has(entry) || + pluginTools.has(entry) || + isDeclaredMcpAllowlistEntry(entry, mcpToolPrefixes); const expanded = expandToolGroups([entry]); const isCoreEntry = expanded.some((tool) => coreTools.has(tool)); if (!isPluginEntry) { diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 04ad95fc6b3..d04174f6631 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -11,6 +11,7 @@ import { isSubagentEnvelopeSession, resolveSubagentCapabilityStore, } from "../agents/subagent-capabilities.js"; +import { buildDeclaredToolAllowlistContext } from "../agents/tool-policy-declared-context.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -232,6 +233,11 @@ export function resolveGatewayScopedTools(params: { { policy: subagentPolicy, label: "subagent tools.allow" }, { policy: inheritedToolPolicy, label: "inherited tools" }, ], + declaredToolAllowlist: buildDeclaredToolAllowlistContext({ + config: params.cfg, + workspaceDir, + toolDenylist: explicitDenylist, + }), }); const gatewayDenySet = new Set([ diff --git a/src/skills/runtime/tool-dispatch.ts b/src/skills/runtime/tool-dispatch.ts index 58a4748af1f..5c9578ae4c2 100644 --- a/src/skills/runtime/tool-dispatch.ts +++ b/src/skills/runtime/tool-dispatch.ts @@ -13,6 +13,7 @@ import { isSubagentEnvelopeSession, resolveSubagentCapabilityStore, } from "../../agents/subagent-capabilities.js"; +import { buildDeclaredToolAllowlistContext } from "../../agents/tool-policy-declared-context.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -230,6 +231,11 @@ export function resolveSkillDispatchTools(params: { { policy: subagentPolicy, label: "subagent tools.allow" }, { policy: inheritedToolPolicy, label: "inherited tools" }, ], + declaredToolAllowlist: buildDeclaredToolAllowlistContext({ + config: params.cfg, + workspaceDir: params.workspaceDir, + toolDenylist: explicitDenylist, + }), }); if (explicitPolicyList.some(hasRestrictiveAllowPolicy)) { replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, policyFiltered);