From 41bbb62ba4c41ce4f29a8011d07a45060c796a81 Mon Sep 17 00:00:00 2001 From: arkptz Date: Tue, 14 Apr 2026 15:00:46 +0300 Subject: [PATCH 1/4] feat(transforms): obfuscate tool names with MD5 hash to avoid API blacklist --- src/transforms.ts | 48 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/transforms.ts b/src/transforms.ts index acf5b00..a2f88b6 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -1,7 +1,28 @@ import { buildBillingHeaderValue } from "./signing.ts" import { config, getModelOverride } from "./model-config.ts" +import { log } from "./logger.ts" + +// 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 +} -const TOOL_PREFIX = "mcp_" +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 +227,27 @@ 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 +264,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 { From 6551d14f879a18f1d2b794ce5034b6d55a7ed2d2 Mon Sep 17 00:00:00 2001 From: arkptz Date: Tue, 14 Apr 2026 15:05:36 +0300 Subject: [PATCH 2/4] docs: update README for tool name obfuscation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7053aa33e3149383baf3046642c7e4cda5a236b2 Mon Sep 17 00:00:00 2001 From: arkptz Date: Tue, 14 Apr 2026 15:12:58 +0300 Subject: [PATCH 3/4] test: update tool name tests for hash-based obfuscation --- src/transforms.test.ts | 112 ++++++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/src/transforms.test.ts b/src/transforms.test.ts index 763a5b5..b502678 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,20 @@ 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 +528,42 @@ 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 +572,21 @@ 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 +606,28 @@ 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 +662,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 +833,20 @@ 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 +869,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}`, ) }) }) From 9420fbef60567968bcd21a260db21be9f7dd475b Mon Sep 17 00:00:00 2001 From: arkptz Date: Tue, 14 Apr 2026 15:30:54 +0300 Subject: [PATCH 4/4] style: remove unused log import and fix formatting --- src/transforms.test.ts | 60 +++++++++++++++++++++++++----------------- src/transforms.ts | 6 +++-- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/transforms.test.ts b/src/transforms.test.ts index b502678..ed11d81 100644 --- a/src/transforms.test.ts +++ b/src/transforms.test.ts @@ -451,10 +451,12 @@ describe("transforms", () => { 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 body = transformBody( + JSON.stringify({ + tools: [{ name: "search" }], + messages: [{ role: "user", content: "test" }], + }), + ) const parsed = JSON.parse(body as string) as { tools: Array<{ name: string }> } @@ -529,10 +531,12 @@ describe("transforms", () => { it("transformResponseStream still strips tool prefixes in error bodies", async () => { // Populate maps via round-trip - const body = transformBody(JSON.stringify({ - tools: [{ name: "search" }], - messages: [{ role: "user", content: "x" }], - })) + 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 }> } @@ -554,10 +558,12 @@ describe("transforms", () => { it("transformResponseStream rewrites streamed tool names", async () => { // Populate maps via round-trip - const body = transformBody(JSON.stringify({ - tools: [{ name: "lookup" }], - messages: [{ role: "user", content: "x" }], - })) + 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 }> } @@ -573,10 +579,12 @@ describe("transforms", () => { it("transformResponseStream buffers across chunks until event boundary", async () => { // Populate maps via round-trip - const body = transformBody(JSON.stringify({ - tools: [{ name: "search" }], - messages: [{ role: "user", content: "x" }], - })) + 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 }> } @@ -613,10 +621,12 @@ describe("transforms", () => { 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 bodyResult = transformBody( + JSON.stringify({ + tools: [{ name: "test" }], + messages: [{ role: "user", content: "x" }], + }), + ) const parsedBody = JSON.parse(bodyResult as string) as { tools: Array<{ name: string }> } @@ -834,10 +844,12 @@ 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 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 }> } diff --git a/src/transforms.ts b/src/transforms.ts index a2f88b6..780ef0f 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -1,6 +1,5 @@ import { buildBillingHeaderValue } from "./signing.ts" import { config, getModelOverride } from "./model-config.ts" -import { log } from "./logger.ts" // Obfuscate tool names: the API blacklists certain tool names (todowrite, // background_output, background_cancel). To avoid detection we hash ALL tool @@ -244,7 +243,10 @@ export function transformBody( if (block.type === "tool_use" && typeof block.name === "string") { return { ...block, name: obfuscateToolName(block.name) } } - if (block.type === "tool_result" && typeof block["tool_use_id"] === "string") { + 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