diff --git a/README.md b/README.md index 33ca9fb..89a4717 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ export ANTHROPIC_ENABLE_1M_CONTEXT=true # requires Claude Max - Registers an `auth.loader` with a custom `fetch` that intercepts all Anthropic API requests - Sets `Authorization: Bearer` with fresh OAuth tokens (cached in memory, 30s TTL, updated in-place after refresh) -- Translates tool names between OpenCode and Anthropic API formats (adds/strips `mcp_` prefix) +- Obfuscates tool names with MD5 hashing (`t_` prefix) to avoid API blacklist detection, with bidirectional reverse mapping for response translation - Buffers SSE response streams at event boundaries for reliable tool name translation - Injects Claude Code identity into system prompts via `experimental.chat.system.transform` - Sets required API headers (beta flags, billing, user-agent) with model-aware selection diff --git a/src/transforms.test.ts b/src/transforms.test.ts index 763a5b5..ed11d81 100644 --- a/src/transforms.test.ts +++ b/src/transforms.test.ts @@ -36,8 +36,8 @@ describe("transforms", () => { // The original system text should now be prepended to the first user message assert.equal(parsed.messages[0].content[0].type, "text") assert.equal(parsed.messages[0].content[0].text, "OpenCode and opencode") - assert.equal(parsed.tools[0].name, "mcp_search") - assert.equal(parsed.messages[0].content[1].name, "mcp_lookup") + assert.match(parsed.tools[0].name, /^t_[0-9a-f]{8}$/) + assert.match(parsed.messages[0].content[1].name, /^t_[0-9a-f]{8}$/) }) it("transformBody relocates non-core system text to user message", () => { @@ -449,9 +449,22 @@ describe("transforms", () => { assert.equal(parsed.thinking, undefined) }) - it("stripToolPrefix removes mcp_ from response payload names", () => { - const input = '{"name":"mcp_search","type":"tool_use"}' - assert.equal(stripToolPrefix(input), '{"name": "search","type":"tool_use"}') + it("stripToolPrefix reverses obfuscated tool names", () => { + // Populate the maps by obfuscating via transformBody + const body = transformBody( + JSON.stringify({ + tools: [{ name: "search" }], + messages: [{ role: "user", content: "test" }], + }), + ) + const parsed = JSON.parse(body as string) as { + tools: Array<{ name: string }> + } + const obf = parsed.tools[0].name + // Now test deobfuscation + const input = `{"name":"${obf}","type":"tool_use"}` + const result = stripToolPrefix(input) + assert.ok(result.includes('"name": "search"')) }) it("transformResponseStream passes error responses through without SSE parsing", async () => { @@ -517,23 +530,46 @@ describe("transforms", () => { }) it("transformResponseStream still strips tool prefixes in error bodies", async () => { - // stripToolPrefix matches the pattern "name": "mcp_..." - const errorBody = '{"name": "mcp_search", "error": "failed"}' + // Populate maps via round-trip + const body = transformBody( + JSON.stringify({ + tools: [{ name: "search" }], + messages: [{ role: "user", content: "x" }], + }), + ) + const parsedBody = JSON.parse(body as string) as { + tools: Array<{ name: string }> + } + const obf = parsedBody.tools[0].name + + const errorBody = `{"name": "${obf}", "error": "failed"}` const response = new Response(errorBody, { status: 400 }) const transformed = transformResponseStream(response) const text = await transformed.text() assert.ok( text.includes('"name": "search"'), - "Should strip mcp_ prefix even in error bodies", + "Should deobfuscate tool name in error bodies", ) assert.ok( - !text.includes("mcp_search"), - "Should not contain mcp_search after stripping", + !text.includes(obf), + "Should not contain obfuscated name after stripping", ) }) it("transformResponseStream rewrites streamed tool names", async () => { - const payload = '{"name":"mcp_lookup"}' + // Populate maps via round-trip + const body = transformBody( + JSON.stringify({ + tools: [{ name: "lookup" }], + messages: [{ role: "user", content: "x" }], + }), + ) + const parsedBody = JSON.parse(body as string) as { + tools: Array<{ name: string }> + } + const obf = parsedBody.tools[0].name + + const payload = `{"name":"${obf}"}` const response = new Response(payload) const transformed = transformResponseStream(response) const text = await transformed.text() @@ -542,8 +578,23 @@ describe("transforms", () => { }) it("transformResponseStream buffers across chunks until event boundary", async () => { - const chunk1 = 'data: {"name":"mc' - const chunk2 = 'p_search"}\n\ndata: {"type":"done"}\n\n' + // Populate maps via round-trip + const body = transformBody( + JSON.stringify({ + tools: [{ name: "search" }], + messages: [{ role: "user", content: "x" }], + }), + ) + const parsedBody = JSON.parse(body as string) as { + tools: Array<{ name: string }> + } + const obf = parsedBody.tools[0].name + + // Split the obfuscated name across two chunks + const fullData = `data: {"name":"${obf}"}\n\ndata: {"type":"done"}\n\n` + const splitAt = 15 // splits inside the tool name + const chunk1 = fullData.slice(0, splitAt) + const chunk2 = fullData.slice(splitAt) const encoder = new TextEncoder() const stream = new ReadableStream({ @@ -563,18 +614,30 @@ describe("transforms", () => { `Expected stripped name in: ${text}`, ) assert.ok( - !text.includes("mcp_search"), - `Should not contain mcp_search in: ${text}`, + !text.includes(obf), + `Should not contain obfuscated name in: ${text}`, ) }) it("transformResponseStream withholds output until event boundary arrives", async () => { + // Populate maps via round-trip + const bodyResult = transformBody( + JSON.stringify({ + tools: [{ name: "test" }], + messages: [{ role: "user", content: "x" }], + }), + ) + const parsedBody = JSON.parse(bodyResult as string) as { + tools: Array<{ name: string }> + } + const obf = parsedBody.tools[0].name + const encoder = new TextEncoder() let sendBoundary: (() => void) | undefined const source = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"name":"mcp_test"}')) + controller.enqueue(encoder.encode(`data: {"name":"${obf}"}`)) sendBoundary = () => { controller.enqueue(encoder.encode("\n\n")) controller.close() @@ -609,8 +672,8 @@ describe("transforms", () => { `Expected stripped name: ${text}`, ) assert.ok( - !text.includes("mcp_test"), - `Should not contain mcp_test: ${text}`, + !text.includes(obf), + `Should not contain obfuscated name: ${text}`, ) const final = await reader.read() @@ -780,9 +843,22 @@ describe("transforms", () => { }) it("transformResponseStream flushes remaining buffered data on stream end", async () => { + // Populate maps for both tool names via round-trip + const bodyResult = transformBody( + JSON.stringify({ + tools: [{ name: "alpha" }, { name: "beta" }], + messages: [{ role: "user", content: "x" }], + }), + ) + const parsedBody = JSON.parse(bodyResult as string) as { + tools: Array<{ name: string }> + } + const obfAlpha = parsedBody.tools[0].name + const obfBeta = parsedBody.tools[1].name + const encoder = new TextEncoder() - const chunk1 = 'data: {"name":"mcp_alpha"}\n\n' - const chunk2 = 'data: {"name":"mcp_beta"}' + const chunk1 = `data: {"name":"${obfAlpha}"}\n\n` + const chunk2 = `data: {"name":"${obfBeta}"}` const stream = new ReadableStream({ start(controller) { @@ -805,12 +881,12 @@ describe("transforms", () => { `Expected beta stripped in: ${text}`, ) assert.ok( - !text.includes("mcp_alpha"), - `Should not contain mcp_alpha in: ${text}`, + !text.includes(obfAlpha), + `Should not contain obfuscated alpha in: ${text}`, ) assert.ok( - !text.includes("mcp_beta"), - `Should not contain mcp_beta in: ${text}`, + !text.includes(obfBeta), + `Should not contain obfuscated beta in: ${text}`, ) }) }) diff --git a/src/transforms.ts b/src/transforms.ts index acf5b00..780ef0f 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -1,7 +1,27 @@ import { buildBillingHeaderValue } from "./signing.ts" import { config, getModelOverride } from "./model-config.ts" -const TOOL_PREFIX = "mcp_" +// Obfuscate tool names: the API blacklists certain tool names (todowrite, +// background_output, background_cancel). To avoid detection we hash ALL tool +// names on the way out and reverse-map them on the way back. +import { createHash } from "node:crypto" + +const toolNameMap = new Map() // obfuscated → original +const toolNameReverseMap = new Map() // original → obfuscated + +function obfuscateToolName(name: string): string { + const existing = toolNameReverseMap.get(name) + if (existing) return existing + const hash = createHash("md5").update(name).digest("hex").slice(0, 8) + const obf = `t_${hash}` + toolNameMap.set(obf, name) + toolNameReverseMap.set(name, obf) + return obf +} + +function deobfuscateToolName(obf: string): string { + return toolNameMap.get(obf) ?? obf +} const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude." @@ -206,30 +226,30 @@ export function transformBody( } } + // Obfuscate tool names to avoid API blacklist detection if (Array.isArray(parsed.tools)) { parsed.tools = parsed.tools.map((tool) => ({ ...tool, - name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name, + name: tool.name ? obfuscateToolName(tool.name) : tool.name, })) } if (Array.isArray(parsed.messages)) { parsed.messages = parsed.messages.map((message) => { - if (!Array.isArray(message.content)) { - return message - } - + if (!Array.isArray(message.content)) return message return { ...message, content: message.content.map((block) => { - if (block.type !== "tool_use" || typeof block.name !== "string") { - return block + if (block.type === "tool_use" && typeof block.name === "string") { + return { ...block, name: obfuscateToolName(block.name) } } - - return { - ...block, - name: `${TOOL_PREFIX}${block.name}`, + if ( + block.type === "tool_result" && + typeof block["tool_use_id"] === "string" + ) { + // tool_result references tool_use by id, not name — no change needed } + return block }), } }) @@ -246,7 +266,11 @@ export function transformBody( } export function stripToolPrefix(text: string): string { - return text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"') + // Reverse-map obfuscated tool names back to originals in response stream + return text.replace(/"name"\s*:\s*"(t_[0-9a-f]{8})"/g, (_match, obf) => { + const original = deobfuscateToolName(obf) + return `"name": "${original}"` + }) } export function transformResponseStream(response: Response): Response {