mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 13:08:07 +00:00
fix(policy): recognize declared tool allowlists (#89596)
Merged via squash.
Prepared head SHA: 3f8628b4fd
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
196
src/agents/tool-policy-declared-context.ts
Normal file
196
src/agents/tool-policy-declared-context.ts
Normal file
@@ -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<typeof compileGlobPatterns>;
|
||||
|
||||
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<string>();
|
||||
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<DeclaredToolAllowlistContext, "pluginIds" | "pluginToolNames"> {
|
||||
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<string>();
|
||||
const pluginToolNames = new Set<string>();
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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<string>;
|
||||
pluginIds?: Iterable<string>;
|
||||
mcpServerNames?: Iterable<string>;
|
||||
};
|
||||
|
||||
/** 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<string>): Set<string> {
|
||||
const prefixes = new Set<string>();
|
||||
const usedNames = new Set<string>();
|
||||
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<string>): Set<string> {
|
||||
return new Set(
|
||||
Array.from(values ?? [], (value) => normalizeOptionalLowercaseString(value)).filter(
|
||||
(value): value is string => Boolean(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDeclaredToolNames(values?: Iterable<string>): Set<string> {
|
||||
return new Set(
|
||||
Array.from(values ?? [], (value) => normalizeToolName(value)).filter((value): value is string =>
|
||||
Boolean(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isDeclaredMcpAllowlistEntry(entry: string, prefixes: Set<string>): 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<string>,
|
||||
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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user