From 786a4f4636542f122163827451de8c34dbb3af23 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 9 Aug 2026 15:35:18 +0900 Subject: [PATCH] fix(anthropic): validate upstream tool names --- src/adapters/anthropic.ts | 8 +++--- tests/anthropic-stream-hardening.test.ts | 31 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 60b27d43a..d112bbe6a 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -999,12 +999,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "content_block_start": { - const block = data.content_block as { type: string; id?: string; name?: string; data?: string } | undefined; + const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string } | undefined; if (!block) break; currentBlockType = block.type; if (block.type === "tool_use") { currentToolCallId = usableToolUseId(block.id); - currentToolCallName = toolNames.fromWire(block.name ?? ""); + currentToolCallName = toolNames.fromWire(typeof block.name === "string" ? block.name : ""); currentToolCallJson = ""; budget.openCall(currentToolCallId); yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName }; @@ -1157,7 +1157,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti budget.chargeRetained(responseBytes, { kind: "retained_collectors" }); try { const events: AdapterEvent[] = []; - const content = json.content as { type: string; text?: string; id?: string; name?: string; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined; + const content = json.content as { type: string; text?: string; id?: string; name?: unknown; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined; if (content) { for (const block of content) { if (block.type === "text" && block.text) { @@ -1173,7 +1173,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti events.push({ type: "redacted_thinking", data: block.data }); } else if (block.type === "tool_use") { const id = usableToolUseId(block.id); - events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") }); + events.push({ type: "tool_call_start", id, name: toolNames.fromWire(typeof block.name === "string" ? block.name : "") }); events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input, provider.anthropicEofTolerance === true) }); events.push({ type: "tool_call_end" }); } diff --git a/tests/anthropic-stream-hardening.test.ts b/tests/anthropic-stream-hardening.test.ts index a4f03caf0..8e7bede6e 100644 --- a/tests/anthropic-stream-hardening.test.ts +++ b/tests/anthropic-stream-hardening.test.ts @@ -28,6 +28,24 @@ describe("anthropicMessagesUrl", () => { }); describe("anthropic stream hardening", () => { + test("malformed escaped tool names degrade to an empty name", async () => { + const response = new Response([ + "event: content_block_start\n", + 'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_bad_name","name":{"bad":1}}}\n\n', + "event: content_block_stop\n", + 'data: {"type":"content_block_stop"}\n\n', + "event: message_stop\n", + 'data: {"type":"message_stop"}\n\n', + ].join("")); + const escapedProvider = { ...provider, escapeBuiltinToolNames: true }; + const events = await collect(createAnthropicAdapter(escapedProvider).parseStream(response)); + expect(events.find(e => e.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + name: "", + }); + expect(events.at(-1)?.type).toBe("done"); + }); + test("EOF after content without message_stop fails closed", async () => { const response = new Response([ "event: content_block_start\n", @@ -102,6 +120,19 @@ describe("anthropic stream hardening", () => { }); describe("anthropic non-stream tool_use input", () => { + test("malformed escaped tool names degrade to an empty name", async () => { + const adapter = createAnthropicAdapter({ ...provider, escapeBuiltinToolNames: true }); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + content: [{ type: "tool_use", id: "toolu_bad_name", name: { bad: 1 }, input: {} }], + stop_reason: "tool_use", + }))); + expect(events.find(e => e.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + name: "", + }); + expect(events.at(-1)?.type).toBe("done"); + }); + test("parses string tool_use.input", async () => { const adapter = createAnthropicAdapter(provider); const events = await adapter.parseResponse!(new Response(JSON.stringify({