diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 46c75f02bf99..c1a6e7cc3427 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -136,11 +136,14 @@ claude update The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run. The Agent SDK always runs with Claude Code's default permission mode. -OpenClaw's existing effective exec policy remains authoritative through SDK -permission callbacks and a `PreToolUse` hook, including when user settings -would otherwise preapprove a tool. Per-agent and session restrictions still -override broader global policy. OpenClaw-owned MCP tools remain authorized by -the Gateway rather than receiving a second Claude-native approval. +OpenClaw's SDK permission callback and `PreToolUse` hook keep native tools under +host control, including when user or enterprise settings would otherwise +preapprove a call. Native requests pass through canonical `before_tool_call` +policy before exec policy and approval, with native tool names and file +arguments projected into their OpenClaw equivalents. Per-agent and session +restrictions still override broader global policy. OpenClaw-owned MCP tools +remain authorized by the Gateway rather than receiving duplicate native +approval; other MCP tools stay host-permission controlled. When the effective exec ask setting is `on-miss` or `always`, OpenClaw relays native or extension tool requests as interactive approvals to the session's diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index eb96089106ed..63eb5cabfac0 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -107,9 +107,11 @@ OpenClaw release: OpenClaw detects the existing Claude CLI login. Normal agent turns use the official Agent SDK with the installed, authenticated Claude Code executable, including native-tool turns whose approvals remain under - OpenClaw control. Imported native OAuth profiles reuse the verified - Claude Code login; explicitly selected API-key or token credentials - still use protected file-descriptor forwarding. Isolated side-question + OpenClaw control. Schema-valid native calls pass through OpenClaw's + canonical tool policy before native approval. Imported native OAuth + profiles reuse the verified Claude Code login; explicitly selected + API-key or token credentials still use protected file-descriptor + forwarding. Isolated side-question completions and paired-node execution retain the supervised CLI path. Consecutive agent turns reuse the same warm Agent SDK query and Claude diff --git a/src/agents/cli-runner/execute-plugin.policy.test.ts b/src/agents/cli-runner/execute-plugin.policy.test.ts new file mode 100644 index 000000000000..e9f86e7517c6 --- /dev/null +++ b/src/agents/cli-runner/execute-plugin.policy.test.ts @@ -0,0 +1,429 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + CliBackendExecute, + CliBackendExecuteContext, + CliBackendToolPermissionResult, +} from "../../plugins/cli-backend.types.js"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../../plugins/hook-runner-global.js"; +import type { PluginHookHandlerMap } from "../../plugins/hook-types.js"; +import { createMockPluginRegistry } from "../../plugins/hooks.test-fixtures.js"; +import { prepareSystemAgentRunAdmission } from "../admitted-run-context.js"; +import * as beforeToolCall from "../agent-tools.before-tool-call.js"; +import { buildPreparedCliRunContext } from "../cli-runner.test-helpers.js"; +import { callGatewayTool } from "../tools/gateway.js"; +import { executePluginOwnedProcess } from "./execute-plugin.js"; +import type { PreparedCliRunContext } from "./types.js"; + +vi.mock("../tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +const mockCallGatewayTool = vi.mocked(callGatewayTool); +const activeAdmissions: Array> = []; +let nextRunId = 0; + +const SUCCESS_RESULT = { + type: "result", + subtype: "success", + is_error: false, + result: "completed", + session_id: "sdk-session", +}; + +function installBeforeToolCallHook( + handler: PluginHookHandlerMap["before_tool_call"], + matcher?: [string, ...string[]], +) { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_tool_call", + handler: (...args) => Reflect.apply(handler, undefined, args), + ...(matcher ? { matcher } : {}), + }, + ]), + ); +} + +async function createExecution( + options: { nativeTools?: string[]; abortSignal?: AbortSignal } = {}, +) { + const runId = "plugin-policy-" + ++nextRunId; + const config = { tools: { exec: { security: "full" as const, ask: "off" as const } } }; + const admission = prepareSystemAgentRunAdmission(config, runId, "main", "plugin-test"); + activeAdmissions.push(admission); + const context = buildPreparedCliRunContext({ + provider: "claude-cli", + model: "claude-sonnet-4-6", + agentId: "main", + runId, + sessionId: "sdk-session", + sessionKey: "agent:main:main", + prompt: "hello", + config, + executionMode: "agent", + timeoutMs: 5_000, + ...(options.nativeTools + ? { cliToolAvailability: { native: options.nativeTools, openClaw: [] } } + : {}), + systemPrompt: "Follow host policy.", + backend: { command: "/bin/sh", args: [] }, + }); + context.params.admittedRunContext = await admission.admit("plugin-harness"); + if (options.abortSignal) { + context.params.abortSignal = options.abortSignal; + } + return { admission, context }; +} + +function runPlugin(context: PreparedCliRunContext, execute: CliBackendExecute) { + return executePluginOwnedProcess({ + context, + execute, + executionCommand: "/bin/sh", + executionArgs: ["-p", "--permission-mode", "bypassPermissions"], + env: { PATH: "/bin:/usr/bin" }, + prompt: context.params.prompt, + useResume: false, + sessionId: "sdk-session", + noOutputTimeoutMs: 2_000, + consumeStdout: () => {}, + }); +} + +function requestNativeTool( + execution: CliBackendExecuteContext, + toolName = "Bash", + toolInput: Record = { command: "echo approved" }, +) { + return execution.requestToolPermission({ + toolName, + toolInput, + toolCallId: "native-" + toolName, + ...(execution.abortSignal ? { abortSignal: execution.abortSignal } : {}), + }); +} + +afterEach(() => { + resetGlobalHookRunner(); + for (const admission of activeAdmissions.splice(0)) { + admission.close(); + } + mockCallGatewayTool.mockReset(); + vi.restoreAllMocks(); +}); + +describe("plugin-owned CLI native tool policy", () => { + it("runs canonical policy before native approval and carries rewritten params plus run context", async () => { + const policy = vi.spyOn(beforeToolCall, "runBeforeToolCallHook"); + const hook = vi.fn(async (_event: unknown, _context: unknown) => ({ + params: { url: "https://example.com/rewritten" }, + })); + installBeforeToolCallHook(hook); + const { context } = await createExecution({ nativeTools: ["WebFetch"] }); + Object.assign(context.params, { + messageChannel: "telegram", + messageProvider: "telegram", + currentChannelId: "chat-1", + chatId: "chat-1", + agentAccountId: "bot-1", + senderId: "user-1", + senderIsOwner: true, + currentThreadTs: "thread-1", + }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, "WebFetch", { + url: "https://example.com/original", + }); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual({ + behavior: "allow", + updatedInput: { url: "https://example.com/rewritten" }, + }); + expect(hook).toHaveBeenCalledOnce(); + expect(hook.mock.calls[0]?.[0]).toMatchObject({ + toolName: "web_fetch", + params: { url: "https://example.com/original" }, + toolCallId: "native-WebFetch", + runId: context.params.runId, + }); + expect(hook.mock.calls[0]?.[1]).toMatchObject({ + agentId: "main", + sessionKey: "agent:main:main", + sessionId: "sdk-session", + runId: context.params.runId, + channelId: "chat-1", + requester: { + channel: "telegram", + accountId: "bot-1", + senderId: "user-1", + senderIsOwner: true, + }, + }); + expect(policy.mock.calls[0]?.[0]).toMatchObject({ + ctx: { + config: context.params.config, + cwd: "/tmp", + workspaceDir: "/tmp", + turnSourceChannel: "telegram", + turnSourceTo: "chat-1", + turnSourceAccountId: "bot-1", + turnSourceThreadId: "thread-1", + loopDetection: undefined, + }, + }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it.each([ + { native: "Bash", canonical: "exec", input: { command: "echo blocked" } }, + { + native: "WebFetch", + canonical: "web_fetch", + input: { url: "https://example.com", prompt: "summarize" }, + }, + { native: "WebSearch", canonical: "web_search", input: { query: "blocked" } }, + ])( + "applies matched $canonical policy to native $native", + async ({ native, canonical, input }) => { + const hook = vi.fn(async () => ({ block: true, blockReason: `${canonical} blocked` })); + installBeforeToolCallHook(hook, [canonical]); + const { context } = await createExecution({ nativeTools: [native] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, native, input); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual({ behavior: "deny", message: `${canonical} blocked` }); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ toolName: canonical, params: input }), + expect.objectContaining({ toolName: canonical }), + ); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { native: "Read", canonical: "read", input: { file_path: "/tmp/private.txt" } }, + { + native: "Write", + canonical: "write", + input: { file_path: "/tmp/private.txt", content: "private" }, + }, + { + native: "Edit", + canonical: "edit", + input: { file_path: "/tmp/private.txt", old_string: "old", new_string: "new" }, + }, + ])( + "applies path-based $canonical policy to native $native", + async ({ native, canonical, input }) => { + const hook = vi.fn(async (event: { params: Record }) => + event.params.path === "/tmp/private.txt" + ? { block: true, blockReason: "private path blocked" } + : undefined, + ); + installBeforeToolCallHook(hook, [canonical]); + const { context } = await createExecution({ nativeTools: [native] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, native, input); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual({ behavior: "deny", message: "private path blocked" }); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: canonical, + params: expect.objectContaining({ path: "/tmp/private.txt" }), + }), + expect.objectContaining({ toolName: canonical }), + ); + }, + ); + + it("projects rewritten canonical file arguments back into the native Edit schema", async () => { + const hook = vi.fn(async () => ({ + params: { + path: "/tmp/approved.txt", + edits: [{ oldText: "safe-before", newText: "safe-after" }], + }, + })); + installBeforeToolCallHook(hook, ["edit"]); + const { context } = await createExecution({ nativeTools: ["Edit"] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, "Edit", { + file_path: "/tmp/original.txt", + old_string: "before", + new_string: "after", + replace_all: false, + }); + yield SUCCESS_RESULT; + }); + + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "edit", + params: expect.objectContaining({ + path: "/tmp/original.txt", + edits: [{ oldText: "before", newText: "after" }], + }), + }), + expect.anything(), + ); + expect(decision).toEqual({ + behavior: "allow", + updatedInput: { + file_path: "/tmp/approved.txt", + old_string: "safe-before", + new_string: "safe-after", + replace_all: false, + }, + }); + }); + + it("rejects conflicting native and canonical paths before invoking policy", async () => { + const hook = vi.fn(async () => undefined); + installBeforeToolCallHook(hook, ["read"]); + const { context } = await createExecution({ nativeTools: ["Read"] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, "Read", { + file_path: "/tmp/private.txt", + path: "/tmp/allowed.txt", + }); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual( + expect.objectContaining({ + behavior: "deny", + message: expect.stringContaining("conflicting"), + }), + ); + expect(hook).not.toHaveBeenCalled(); + }); + + it("rejects canonical edit rewrites that the native tool cannot represent", async () => { + const hook = vi.fn(async () => ({ + params: { + edits: [ + { oldText: "first", newText: "one" }, + { oldText: "second", newText: "two" }, + ], + }, + })); + installBeforeToolCallHook(hook, ["edit"]); + const { context } = await createExecution({ nativeTools: ["Edit"] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, "Edit", { + file_path: "/tmp/file.txt", + old_string: "before", + new_string: "after", + }); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual( + expect.objectContaining({ + behavior: "deny", + message: expect.stringContaining("native edit"), + }), + ); + }); + + it.each([ + { + name: "blocks", + handler: vi.fn(async () => ({ block: true, blockReason: "blocked by plugin policy" })), + message: "blocked by plugin policy", + }, + { + name: "fails", + handler: vi.fn(async () => { + throw new Error("policy crashed"); + }), + message: "before_tool_call hook failed", + }, + ])("fails closed when before_tool_call $name", async ({ handler, message }) => { + installBeforeToolCallHook(handler); + const { context } = await createExecution({ nativeTools: ["Bash"] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual( + expect.objectContaining({ behavior: "deny", message: expect.stringContaining(message) }), + ); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("aborts before_tool_call without reaching native approval", async () => { + const controller = new AbortController(); + const hook = vi.fn( + async (_event: unknown, hookContext: { abortSignal?: AbortSignal }): Promise => + await new Promise((_, reject) => { + hookContext.abortSignal?.addEventListener( + "abort", + () => reject(new Error("policy aborted")), + { once: true }, + ); + }), + ); + installBeforeToolCallHook(hook); + const { context } = await createExecution({ + abortSignal: controller.signal, + nativeTools: ["Bash"], + }); + const run = runPlugin(context, async function* (execution) { + await requestNativeTool(execution); + yield SUCCESS_RESULT; + }); + await vi.waitFor(() => expect(hook).toHaveBeenCalledOnce()); + + controller.abort(new Error("cancel policy")); + + await expect(run).rejects.toMatchObject({ name: "AbortError" }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("fails closed when canonical policy returns a non-record rewrite", async () => { + vi.spyOn(beforeToolCall, "runBeforeToolCallHook").mockResolvedValueOnce({ + blocked: false, + params: "invalid", + }); + const { context } = await createExecution({ nativeTools: ["Bash"] }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual( + expect.objectContaining({ + behavior: "deny", + message: expect.stringContaining("invalid input"), + }), + ); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/cli-runner/execute-plugin.ts b/src/agents/cli-runner/execute-plugin.ts index 135b2e9f01c9..932c7004034a 100644 --- a/src/agents/cli-runner/execute-plugin.ts +++ b/src/agents/cli-runner/execute-plugin.ts @@ -13,11 +13,14 @@ import type { } from "../../plugins/cli-backend.types.js"; import type { RunExit, TerminationReason } from "../../process/supervisor/types.js"; import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js"; +import { runBeforeToolCallHook } from "../agent-tools.before-tool-call.js"; import type { CliTerminalInterruption } from "../cli-output-contracts.js"; import { resolveExecDefaults } from "../exec-defaults.js"; import { isSignalTimeoutReason, type FailoverError } from "../failover-error.js"; import { runStructuredInput } from "../harness/structured-input-execution.js"; import { compileStructuredInputQuestions } from "../harness/structured-input.js"; +import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js"; +import { normalizeToolPolicyName } from "../tool-policy.js"; import { callGatewayTool } from "../tools/gateway.js"; import { closeCliLiveSession, @@ -75,6 +78,123 @@ function createPluginToolPermissionHandler(params: { return denyTool(`OpenClaw denied native tool ${toolName}: it is unavailable to this run.`); } + // Provider schemas are not policy schemas: match canonical names and file operands. + const canonicalToolName = normalizeToolPolicyName( + toolName.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2"), + ); + const nativeFileTool = + ["read", "write", "edit"].includes(canonicalToolName) && + Object.hasOwn(request.toolInput, "file_path"); + let policyInput = request.toolInput; + if (nativeFileTool) { + const nativePath = request.toolInput.file_path; + if (typeof nativePath !== "string") { + return denyTool("OpenClaw denied native file tool use: invalid file path."); + } + if (Object.hasOwn(request.toolInput, "path") && request.toolInput.path !== nativePath) { + return denyTool("OpenClaw denied native file tool use: conflicting file paths."); + } + policyInput = { ...request.toolInput, path: nativePath }; + if (canonicalToolName === "edit") { + const { old_string: oldText, new_string: newText, edits } = request.toolInput; + if (typeof oldText !== "string" || typeof newText !== "string") { + return denyTool("OpenClaw denied native edit tool use: invalid replacement."); + } + if ( + edits !== undefined && + (!Array.isArray(edits) || + edits.length !== 1 || + !isRecord(edits[0]) || + edits[0].oldText !== oldText || + edits[0].newText !== newText) + ) { + return denyTool("OpenClaw denied native edit tool use: conflicting replacements."); + } + policyInput.edits = [{ oldText, newText }]; + } + } + + const requester = { + ...((run.messageChannel ?? run.messageProvider) + ? { channel: run.messageChannel ?? run.messageProvider } + : {}), + ...(run.agentAccountId ? { accountId: run.agentAccountId } : {}), + ...(run.senderId ? { senderId: run.senderId } : {}), + ...(run.senderIsOwner !== undefined ? { senderIsOwner: run.senderIsOwner } : {}), + }; + const hookResult = await runBeforeToolCallHook({ + toolName: canonicalToolName, + params: policyInput, + ...(request.toolCallId ? { toolCallId: request.toolCallId } : {}), + signal, + ctx: { + ...(run.agentId ? { agentId: run.agentId } : {}), + ...(run.config ? { config: run.config } : {}), + cwd: params.context.cwd ?? params.context.workspaceDir, + workspaceDir: params.context.workspaceDir, + ...(run.sessionKey ? { sessionKey: run.sessionKey } : {}), + sessionId: run.sessionId, + runId: run.runId, + ...(run.trigger ? { trigger: run.trigger } : {}), + ...(run.approvalReviewerDeviceId + ? { approvalReviewerDeviceId: run.approvalReviewerDeviceId } + : {}), + ...(run.currentChannelId ? { channelId: run.currentChannelId } : {}), + ...(Object.keys(requester).length > 0 ? { requester } : {}), + turnSourceChannel: run.messageChannel ?? run.messageProvider, + turnSourceTo: run.chatId ?? run.currentChannelId, + turnSourceAccountId: run.agentAccountId, + turnSourceThreadId: run.currentThreadTs, + loopDetection: resolveToolLoopDetectionConfig({ + cfg: run.config, + agentId: run.agentId, + }), + }, + }); + try { + assertActive(); + } catch { + return denyTool("OpenClaw denied native tool use: the admitted run closed during policy."); + } + if (hookResult.blocked) { + return denyTool(hookResult.reason); + } + if (!isRecord(hookResult.params)) { + return denyTool("OpenClaw denied native tool use: before_tool_call returned invalid input."); + } + let toolInput = hookResult.params; + // SDK permission replies must return the native schema, never policy-only aliases. + if (nativeFileTool) { + if (typeof toolInput.path !== "string") { + return denyTool("OpenClaw denied native file tool use: invalid rewritten file path."); + } + if (toolInput === policyInput) { + toolInput = request.toolInput; + } else { + toolInput = { ...toolInput, file_path: toolInput.path }; + if (!Object.hasOwn(request.toolInput, "path")) { + delete toolInput.path; + } + if (canonicalToolName === "edit") { + const edits = toolInput.edits; + if ( + !Array.isArray(edits) || + edits.length !== 1 || + !isRecord(edits[0]) || + typeof edits[0].oldText !== "string" || + typeof edits[0].newText !== "string" + ) { + return denyTool("OpenClaw denied an unrepresentable native edit rewrite."); + } + toolInput.old_string = edits[0].oldText; + toolInput.new_string = edits[0].newText; + if (!Object.hasOwn(request.toolInput, "edits")) { + delete toolInput.edits; + } + } + } + } + const plan = resolveCliNativeToolApprovalPlan(permission); if (plan === "deny") { return denyTool( @@ -84,7 +204,7 @@ function createPluginToolPermissionHandler(params: { const currentGrants = getCliLiveSessionApprovalGrants(params.context) ?? grants; if (plan === "allow" || (permission.ask !== "always" && currentGrants.has(toolName))) { assertActive(); - return { behavior: "allow", updatedInput: request.toolInput }; + return { behavior: "allow", updatedInput: toolInput }; } params.onPendingApproval(1); @@ -92,7 +212,7 @@ function createPluginToolPermissionHandler(params: { try { outcome = await requestCliNativeToolApproval({ toolName, - toolInput: request.toolInput, + toolInput, pluginId: params.context.backendResolved.id, sessionKey: run.sessionKey, agentId: run.agentId, @@ -122,7 +242,7 @@ function createPluginToolPermissionHandler(params: { if (outcome.grantAlways) { currentGrants.add(toolName); } - return { behavior: "allow", updatedInput: request.toolInput }; + return { behavior: "allow", updatedInput: toolInput }; }; } diff --git a/src/plugins/tool-hook-matcher.test.ts b/src/plugins/tool-hook-matcher.test.ts index 0787dd9e2782..bc7cc5e779af 100644 --- a/src/plugins/tool-hook-matcher.test.ts +++ b/src/plugins/tool-hook-matcher.test.ts @@ -11,9 +11,16 @@ describe("plugin tool hook matchers", () => { "apply_patch", "exec", ]); + expect(normalizePluginToolMatcher(["read", "write", "edit"])).toEqual([ + "edit", + "read", + "write", + ]); expect(pluginToolMatcherCoversTool(["exec"], "exec")).toBe(true); expect(pluginToolMatcherCoversTool(["exec"], "Bash")).toBe(false); expect(pluginToolMatcherCoversTool(["apply_patch"], "Write")).toBe(false); + expect(pluginToolMatcherCoversTool(["write"], "write")).toBe(true); + expect(pluginToolMatcherCoversTool(["edit"], "edit")).toBe(true); expect(pluginToolMatcherCoversTool(["spawn_agent"], "Agent")).toBe(false); }); diff --git a/src/plugins/tool-hook-matcher.ts b/src/plugins/tool-hook-matcher.ts index 844f528c1313..bc65ef844225 100644 --- a/src/plugins/tool-hook-matcher.ts +++ b/src/plugins/tool-hook-matcher.ts @@ -12,8 +12,8 @@ const NON_CANONICAL_TOOL_MATCHER_NAMES = new Set([ "bash", "exec_command", "apply-patch", - "write", - "edit", + "Write", + "Edit", "agent", ]); @@ -44,7 +44,10 @@ export function normalizePluginToolMatcher(matcher: unknown): PluginToolMatcher if (canonicalToolName === "*") { throw new TypeError("tool hook matcher wildcard entries are not supported"); } - if (NON_CANONICAL_TOOL_MATCHER_NAMES.has(canonicalToolName)) { + if ( + NON_CANONICAL_TOOL_MATCHER_NAMES.has(canonicalToolName) || + NON_CANONICAL_TOOL_MATCHER_NAMES.has(toolName.trim()) + ) { throw new TypeError("tool hook matcher entries must use canonical OpenClaw tool ids"); } normalized.add(canonicalToolName);