From e4c61723cd2d530680cc61789311d464ab8cdf60 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 08:39:49 -0700 Subject: [PATCH] ACP: fail closed on conflicting tool identity hints (#46817) * ACP: fail closed on conflicting tool identity hints * ACP: restore rawInput fallback for safe tool resolution * ACP tests: cover rawInput-only safe tool approval --- CHANGELOG.md | 1 + src/acp/client.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/acp/client.ts | 17 ++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b50a557d97..4bcd43d2b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark. - Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc. - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) +- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. Thanks @vincentkoc. - Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent. - Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index 0cbc376720c..2595e89bfee 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -366,6 +366,47 @@ describe("resolvePermissionRequest", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("auto-approves safe tools when rawInput is the only identity hint", async () => { + const prompt = vi.fn(async () => true); + const res = await resolvePermissionRequest( + makePermissionRequest({ + toolCall: { + toolCallId: "tool-raw-only", + title: "Searching files", + status: "pending", + rawInput: { + name: "search", + query: "foo", + }, + }, + }), + { prompt, log: () => {} }, + ); + expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } }); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("prompts when raw input spoofs a safe tool name for a dangerous title", async () => { + const prompt = vi.fn(async () => false); + const res = await resolvePermissionRequest( + makePermissionRequest({ + toolCall: { + toolCallId: "tool-exec-spoof", + title: "exec: cat /etc/passwd", + status: "pending", + rawInput: { + command: "cat /etc/passwd", + name: "search", + }, + }, + }), + { prompt, log: () => {} }, + ); + expect(prompt).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledWith(undefined, "exec: cat /etc/passwd"); + expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } }); + }); + it("prompts for read outside cwd scope", async () => { const prompt = vi.fn(async () => false); const res = await resolvePermissionRequest( diff --git a/src/acp/client.ts b/src/acp/client.ts index 2f3ac28641a..1d25281cce5 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -104,7 +104,22 @@ function resolveToolNameForPermission(params: RequestPermissionRequest): string const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]); const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]); const fromTitle = parseToolNameFromTitle(toolCall?.title); - return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? ""); + const metaName = fromMeta ? normalizeToolName(fromMeta) : undefined; + const rawInputName = fromRawInput ? normalizeToolName(fromRawInput) : undefined; + const titleName = fromTitle; + if ((fromMeta && !metaName) || (fromRawInput && !rawInputName)) { + return undefined; + } + if (metaName && titleName && metaName !== titleName) { + return undefined; + } + if (rawInputName && metaName && rawInputName !== metaName) { + return undefined; + } + if (rawInputName && titleName && rawInputName !== titleName) { + return undefined; + } + return metaName ?? titleName ?? rawInputName; } function extractPathFromToolTitle(