From 23dad7d93dbe3b64f794ef8d480eecdbc8654c4e Mon Sep 17 00:00:00 2001 From: NomenAK Date: Sat, 30 May 2026 13:59:36 +0000 Subject: [PATCH] fix(claude): apply tool cloak + schema sanitize on the CLIProxyAPI executor path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Claude OAuth guard in executors/base.ts is bypassed when `upstream_proxy_config.mode = cliproxyapi` routes the request through the CliproxyAPI executor — it has its own execute()/transformRequest() and never reaches BaseExecutor.execute(), so the cloak/sanitizer never ran for that (common) deployment. Wire the same guards into CliproxyapiExecutor.transformRequest (Anthropic-shape branch), composing with the existing bisected `mcp_*` reserved-namespace rewrite: - sanitizeClaudeToolSchemas() on transformed.tools. - cloakThirdPartyToolNames() with skip = mcp-reserved, so applyMcpToolNameRewrite keeps authority over `mcp_*` (its bisected `Mcp_X` form) and the two reverse maps stay disjoint / single-hop. Both merge into the non-enumerable _toolNameMap the response stream already uses to restore the caller's names. cloakThirdPartyToolNames is now non-mutating (clones changed entries) to respect transformRequest's no-input-mutation contract, and takes an optional `skip` predicate. Verified end-to-end through the live CPA path: a real ~100-tool harness payload that returned the "out of extra usage" placeholder now returns 200 with original tool names restored on the response stream; `mcp_*` tools and genuine PascalCase Claude Code tools are unaffected. Co-Authored-By: Claude Opus 4.8 --- open-sse/executors/cliproxyapi.ts | 35 ++++++++++++- open-sse/services/claudeCodeToolRemapper.ts | 55 +++++++++++++++------ tests/unit/claude-oauth-tool-cloak.test.ts | 28 +++++++++++ 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index eeb238b622..c4b2047f2a 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -22,6 +22,8 @@ import { type ProviderCredentials, } from "./base.ts"; import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts"; +import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; const DEFAULT_PORT = 8317; const DEFAULT_HOST = "127.0.0.1"; @@ -335,9 +337,38 @@ export class CliproxyapiExecutor extends BaseExecutor { // uses (utils/stream.ts:restoreClaudePassthroughToolUseName) to // rewrite tool_use.name back to the client's original namespace on // the response side. Capy sees mcp_call back in tool_use blocks. - const toolNameMap = applyMcpToolNameRewrite(transformed); + // Sanitize invalid tool input_schemas (truncation placeholders such as + // `enum: "[MaxDepth]"`, or index-keyed objects where arrays are required) + // that Anthropic rejects with `tools.N.custom.input_schema: JSON schema is + // invalid` — surfaced as the same misleading "out of extra usage" 400. + if (Array.isArray(transformed.tools)) { + transformed.tools = sanitizeClaudeToolSchemas(transformed.tools) as unknown[]; + } + + // Cloak third-party / blacklisted tool names (e.g. `mixture_of_agents`, or + // a large enough set of recognizable snake_case agent tools) that Anthropic + // fingerprints and refuses with the same placeholder. The `mcp_*` reserved + // namespace is deferred to applyMcpToolNameRewrite below (its bisected + // `Mcp_X` form) so the two reverse maps stay disjoint and single-hop. + const cloakMap = cloakThirdPartyToolNames(transformed, { + skip: (name) => MCP_RESERVED_PREFIX_RE.test(name), + }); + + const mcpMap = applyMcpToolNameRewrite(transformed); + + const toolNameMap = new Map(cloakMap); + for (const [alias, original] of mcpMap) { + toolNameMap.set(alias, original); + } if (toolNameMap.size > 0) { - transformed._toolNameMap = toolNameMap; + // Non-enumerable: chatCore reads this for response-side tool-name + // restoration; the wire body must never carry it (also stripped in execute()). + Object.defineProperty(transformed, "_toolNameMap", { + value: toolNameMap, + enumerable: false, + configurable: true, + writable: true, + }); } } diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 688b8381b4..76b1f9f85a 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -197,7 +197,22 @@ export function needsThirdPartyCloak(name: string): boolean { return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-"); } -export function cloakThirdPartyToolNames(body: Record): Map { +export interface CloakOptions { + /** + * Names matching this predicate are left untouched, so a caller that owns a + * more specific rewrite (e.g. the CliproxyAPI executor's Anthropic `mcp_*` + * reserved-namespace rewrite) keeps authority over them and the two reverse + * maps stay disjoint / single-hop. + */ + skip?: (name: string) => boolean; +} + +export function cloakThirdPartyToolNames( + body: Record, + options?: CloakOptions +): Map { + const shouldCloak = (name: string): boolean => + needsThirdPartyCloak(name) && !(options?.skip ? options.skip(name) : false); const tools = body.tools as Array> | undefined; const used = new Set(); @@ -234,34 +249,46 @@ export function cloakThirdPartyToolNames(body: Record): Map { + if (tool && typeof tool.name === "string" && shouldCloak(tool.name)) { + return { ...tool, name: aliasFor(tool.name) }; } - } + return tool; + }); } const messages = body.messages as Array> | undefined; if (Array.isArray(messages)) { - for (const message of messages) { + body.messages = messages.map((message) => { const content = message?.content as Array> | undefined; - if (!Array.isArray(content)) continue; - for (const block of content) { + if (!Array.isArray(content)) return message; + let changed = false; + const newContent = content.map((block) => { if ( block?.type === "tool_use" && typeof block.name === "string" && - needsThirdPartyCloak(block.name) + shouldCloak(block.name) ) { - block.name = aliasFor(block.name); + changed = true; + return { ...block, name: aliasFor(block.name) }; } - } - } + return block; + }); + return changed ? { ...message, content: newContent } : message; + }); } const toolChoice = body.tool_choice as Record | undefined; - if (toolChoice?.type === "tool" && typeof toolChoice.name === "string" && needsThirdPartyCloak(toolChoice.name)) { - toolChoice.name = aliasFor(toolChoice.name); + if ( + toolChoice?.type === "tool" && + typeof toolChoice.name === "string" && + shouldCloak(toolChoice.name) + ) { + body.tool_choice = { ...toolChoice, name: aliasFor(toolChoice.name) }; } return nameMap ?? new Map(); diff --git a/tests/unit/claude-oauth-tool-cloak.test.ts b/tests/unit/claude-oauth-tool-cloak.test.ts index 0e5a52e900..1d4295359b 100644 --- a/tests/unit/claude-oauth-tool-cloak.test.ts +++ b/tests/unit/claude-oauth-tool-cloak.test.ts @@ -179,3 +179,31 @@ describe("cloakThirdPartyToolNames — defensive null guards", () => { assert.equal(block.name, "Read"); }); }); + +describe("cloakThirdPartyToolNames — non-mutating + skip option", () => { + it("does not mutate the caller's input tool objects", () => { + const original: AnyRecord = { name: "read_file" }; + const body: AnyRecord = { tools: [original] }; + cloakThirdPartyToolNames(body); + assert.equal(original.name, "read_file"); // input object untouched + assert.equal((body.tools as AnyRecord[])[0].name, "Read"); // body.tools reassigned with a clone + }); + + it("does not mutate the caller's input message blocks", () => { + const block: AnyRecord = { type: "tool_use", name: "read_file" }; + const body: AnyRecord = { + tools: [{ name: "read_file" }], + messages: [{ role: "assistant", content: [block] }], + }; + cloakThirdPartyToolNames(body); + assert.equal(block.name, "read_file"); // input block untouched + const out = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0]; + assert.equal(out.name, "Read"); + }); + + it("leaves names matched by the skip predicate untouched", () => { + const body: AnyRecord = { tools: [{ name: "mcp_call" }, { name: "read_file" }] }; + cloakThirdPartyToolNames(body, { skip: (n) => n.startsWith("mcp_") }); + assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["mcp_call", "Read"]); + }); +});