fix(agents): guard message provider tool name reads

This commit is contained in:
Vincent Koc
2026-06-03 09:04:15 +02:00
parent f60943717e
commit 2170cb4938
2 changed files with 68 additions and 10 deletions

View File

@@ -4,7 +4,10 @@
* unsafe or redundant for the active channel.
*/
import { describe, expect, it } from "vitest";
import { filterToolNamesByMessageProvider } from "./agent-tools.message-provider-policy.js";
import {
filterToolNamesByMessageProvider,
filterToolsByMessageProvider,
} from "./agent-tools.message-provider-policy.js";
const DEFAULT_TOOL_NAMES = ["read", "write", "tts", "web_search"];
@@ -21,4 +24,32 @@ describe("createOpenClawCodingTools message provider policy", () => {
const names = new Set(filterToolNamesByMessageProvider(DEFAULT_TOOL_NAMES, "guildchat"));
expect(names.has("tts")).toBe(true);
});
it("omits unreadable tool names while applying provider policy", () => {
const readTool = { name: "read" };
const malformedTool = {
get name(): string {
throw new Error("fuzzed unreadable tool name");
},
};
const ttsTool = { name: "tts" };
expect(filterToolsByMessageProvider([readTool, malformedTool, ttsTool], "voice")).toEqual([
readTool,
]);
});
it("does not read tool names when no provider policy applies", () => {
const readTool = { name: "read" };
const malformedTool = {
get name(): string {
throw new Error("fuzzed unreadable tool name");
},
};
expect(filterToolsByMessageProvider([readTool, malformedTool], "guildchat")).toEqual([
readTool,
malformedTool,
]);
});
});

View File

@@ -14,6 +14,19 @@ const TOOL_ALLOW_BY_MESSAGE_PROVIDER: Readonly<Record<string, readonly string[]>
node: ["canvas", "image", "pdf", "tts", "web_fetch", "web_search"],
};
type ReadableToolName<TTool> = {
readonly name: string;
readonly tool: TTool;
};
function readToolName(tool: { name: string }): string | undefined {
try {
return tool.name;
} catch {
return undefined;
}
}
/** Filters tool names by the active message-provider allow/deny policy. */
export function filterToolNamesByMessageProvider(
toolNames: readonly string[],
@@ -41,22 +54,36 @@ export function filterToolsByMessageProvider<TTool extends { name: string }>(
tools: readonly TTool[],
messageProvider?: string,
): TTool[] {
const normalizedProvider = normalizeOptionalLowercaseString(messageProvider);
if (!normalizedProvider) {
return [...tools];
}
const allowedTools = TOOL_ALLOW_BY_MESSAGE_PROVIDER[normalizedProvider];
const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider];
if ((!allowedTools || allowedTools.length === 0) && (!deniedTools || deniedTools.length === 0)) {
return [...tools];
}
const readableTools: ReadableToolName<TTool>[] = [];
for (const tool of tools) {
const name = readToolName(tool);
if (name) {
readableTools.push({ name, tool });
}
}
const filteredToolNames = filterToolNamesByMessageProvider(
tools.map((tool) => tool.name),
messageProvider,
readableTools.map((tool) => tool.name),
normalizedProvider,
);
const remainingCounts = new Map<string, number>();
for (const toolName of filteredToolNames) {
remainingCounts.set(toolName, (remainingCounts.get(toolName) ?? 0) + 1);
}
return tools.filter((tool) => {
// Counted matching preserves the original order and duplicate instances
// after name-level policy filtering.
const remaining = remainingCounts.get(tool.name) ?? 0;
return readableTools.flatMap(({ name, tool }) => {
const remaining = remainingCounts.get(name) ?? 0;
if (remaining <= 0) {
return false;
return [];
}
remainingCounts.set(tool.name, remaining - 1);
return true;
remainingCounts.set(name, remaining - 1);
return [tool];
});
}