From 8b074d2c29e5478d0fc7d0fd8be6759bb71ffadc Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Sun, 31 May 2026 02:18:29 +0200 Subject: [PATCH] fix(claude): sanitize tool schemas + cloak third-party tool names on native Claude OAuth (#2943) Integrated into release/v3.8.8 --- .env.example | 8 + docs/reference/ENVIRONMENT.md | 1 + open-sse/executors/base.ts | 10 +- open-sse/executors/cliproxyapi.ts | 35 ++- open-sse/services/claudeCodeToolRemapper.ts | 160 +++++++++++ open-sse/translator/helpers/schemaCoercion.ts | 153 ++++++++++ tests/unit/claude-oauth-tool-cloak.test.ts | 272 ++++++++++++++++++ tests/unit/cliproxyapi-executor.test.ts | 8 +- 8 files changed, 642 insertions(+), 5 deletions(-) create mode 100644 tests/unit/claude-oauth-tool-cloak.test.ts diff --git a/.env.example b/.env.example index 36ec68c8f6..c57d416049 100644 --- a/.env.example +++ b/.env.example @@ -683,6 +683,14 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Update these when providers release new CLI versions to avoid blocks. CLAUDE_USER_AGENT="claude-cli/2.1.146 (external, cli)" + +# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths +# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — +# third-party-harness tool names are aliased to +# Claude Code canonical or PascalCase forms so Anthropic does not refuse the +# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to +# forward the original names verbatim (debugging only). +# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3c03e5ac41..629e30a20b 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -435,6 +435,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | | `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 084db6a919..b04b6a073c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -21,8 +21,9 @@ import { modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; +import { cloakThirdPartyToolNames, remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; +import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts"; import { @@ -775,6 +776,13 @@ export class BaseExecutor { stripProxyToolPrefix(tb); remapToolNamesInRequest(tb); + // Cloak third-party tool names + sanitize invalid tool schemas so + // Anthropic does not refuse native Claude OAuth traffic with a + // misleading "out of extra usage" placeholder. See Spec E. + cloakThirdPartyToolNames(tb); + if (Array.isArray(tb.tools)) { + tb.tools = sanitizeClaudeToolSchemas(tb.tools); + } obfuscateInBody(tb); // NOTE (issue #2260): This is the native `claude` provider OAuth path. 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 9b61b826f7..16c97a7b3b 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -144,3 +144,163 @@ export function remapToolNamesInResponse( } export { TOOL_RENAME_MAP, REVERSE_MAP }; + +/** + * Anthropic fingerprints third-party agent harnesses by their tool NAMES on the + * first-party Messages API (native Claude OAuth). Two failure modes, both + * surfaced as a misleading `400 out of extra usage` placeholder (the SSE stream + * is refused, not a real billing event): + * 1. Specific blacklisted names (e.g. `mixture_of_agents`) are refused even in + * isolation. + * 2. A large enough SET of recognizable snake_case agent tool names is + * refused collectively, even though each name passes on its own. + * + * `remapToolNamesInRequest` only normalizes the fixed set of Claude Code tool + * names. This generalizes that cloak: any tool name that does not already look + * like a genuine Claude Code tool (PascalCase, no separators) is deterministically + * aliased — to its Claude Code canonical equivalent when one exists, otherwise to + * a PascalCase form of the original. The per-request alias is tracked in the + * non-enumerable `_toolNameMap`, so `remapToolNamesInResponse` restores the + * caller's original names transparently. Disable with + * `CLAUDE_DISABLE_TOOL_NAME_CLOAK=true`. + */ +const CLAUDE_BUILTIN_TOOL_NAMES = new Set(Object.values(TOOL_RENAME_MAP)); + +const HARNESS_CANONICAL_MAP: Record = { + read_file: "Read", + write_file: "Write", + search_files: "Grep", + grep_search: "Grep", + list_directory: "Glob", + run_command: "Bash", + terminal: "Bash", + todo: "TodoWrite", + todo_write: "TodoWrite", + todo_read: "TodoRead", + patch: "Edit", + multi_edit: "MultiEdit", +}; + +function toPascalCaseToolName(name: string): string { + const parts = name.split(/[_\s-]+/).filter(Boolean); + const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); + return pascal || name; +} + +/** + * A name is left untouched when it already reads as a genuine Claude Code tool: + * a PascalCase single token with no separators (Bash, Read, TodoWrite). + */ +export function needsThirdPartyCloak(name: string): boolean { + if (!name) return false; + if (CLAUDE_BUILTIN_TOOL_NAMES.has(name)) return false; + return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-"); +} + +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 { + // Operator kill-switch (documented in .env.example / ENVIRONMENT.md). Checked + // here so every call site — native base.ts AND the CLIProxyAPI executor — + // honours it, rather than each caller having to remember to guard. + if (process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK === "true") { + return new 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(); + if (Array.isArray(tools)) { + for (const tool of tools) { + if (tool && typeof tool.name === "string") used.add(tool.name); + } + } + const existingMap = + body._toolNameMap instanceof Map ? (body._toolNameMap as Map) : null; + if (existingMap) { + for (const alias of existingMap.keys()) used.add(alias); + } + + // Created lazily so genuine Claude Code traffic (nothing to cloak) does not + // get an empty _toolNameMap attached to the request body. + let nameMap: Map | null = existingMap; + const assigned = new Map(); // original -> alias + + const aliasFor = (original: string): string => { + const existing = assigned.get(original); + if (existing) return existing; + // Prefer the established Claude Code rename maps (TOOL_RENAME_MAP spreads + // EXTRA_TOOL_RENAME_MAP) so the CPA path matches the native path exactly: + // subagents->SubDispatch, session_status->CheckStatus, webfetch->WebFetch, … + // Then harness-canonical (read_file->Read), then a generic PascalCase. + const base = + TOOL_RENAME_MAP[original] ?? HARNESS_CANONICAL_MAP[original] ?? toPascalCaseToolName(original); + let alias = base; + let suffix = 2; + while (alias !== original && used.has(alias)) { + alias = `${base}${suffix++}`; + } + used.delete(original); + used.add(alias); + assigned.set(original, alias); + if (!nameMap) nameMap = getRequestToolNameMap(body); + nameMap.set(alias, original); + return alias; + }; + + // Non-mutating: clone changed entries rather than rewriting the caller's + // objects in place (mirrors applyMcpToolNameRewrite — transformRequest must + // not corrupt an input body that may be logged or replayed on fallback). + if (Array.isArray(tools)) { + body.tools = tools.map((tool) => { + 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)) { + body.messages = messages.map((message) => { + const content = message?.content as Array> | undefined; + if (!Array.isArray(content)) return message; + let changed = false; + const newContent = content.map((block) => { + if ( + block?.type === "tool_use" && + typeof block.name === "string" && + shouldCloak(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" && + shouldCloak(toolChoice.name) + ) { + body.tool_choice = { ...toolChoice, name: aliasFor(toolChoice.name) }; + } + + return nameMap ?? new Map(); +} diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index eb47cb4483..28f9b27cf5 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -232,3 +232,156 @@ export function injectEmptyReasoningContentForToolCalls( return { ...message, reasoning_content: "" }; }); } + +/** + * Anthropic's first-party Messages API strictly validates tool `input_schema` + * against JSON Schema draft 2020-12. IDE/SDK agent harnesses that deep-truncate + * their schemas emit invalid constructs — most commonly an array keyword + * (`enum`, `required`, …) replaced by a placeholder string such as + * `"[MaxDepth]"`, or an index-keyed object (`{"0":"a","1":"b"}`) where an array + * is expected. Anthropic rejects these with + * `tools.N.custom.input_schema: JSON schema is invalid` (surfaced as a + * misleading `400 out of extra usage` placeholder when streaming). Non-Anthropic + * targets (OpenAI/Codex) tolerate them, which is why the same request succeeds + * on a fallback provider. This sanitizer coerces or drops the invalid + * constructs so legitimate native-Claude-OAuth traffic is not spuriously + * rejected. See Spec E (Claude Code OAuth wire compatibility). + */ +const SCHEMA_PLACEHOLDER_PATTERN = /^\[(?:MaxDepth|Truncated|Circular|Object|Array)\]$/; +const ARRAY_SCHEMA_KEYS = ["enum", "required", "anyOf", "oneOf", "allOf", "prefixItems"]; +const SCHEMA_ARRAY_OF_SCHEMAS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]); +const SCHEMA_SLOT_KEYS = [ + "items", + "additionalProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + "unevaluatedProperties", + "additionalItems", +]; + +function coerceIndexedObjectToArray(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value; + if (isPlainObject(value)) { + const keys = Object.keys(value); + if (keys.length > 0 && keys.every((key, index) => String(index) === key)) { + return keys.map((key) => value[key]); + } + } + return null; +} + +function isSchemaPlaceholder(value: unknown): boolean { + return typeof value === "string" && SCHEMA_PLACEHOLDER_PATTERN.test(value.trim()); +} + +export function stripInvalidSchemaConstructs(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripInvalidSchemaConstructs(entry)); + } + if (!isPlainObject(schema)) { + return isSchemaPlaceholder(schema) ? {} : schema; + } + + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(schema)) { + // Coerce string-encoded numeric constraints (e.g. minimum: "5") to numbers — + // Anthropic rejects the string form. Done here so the Claude sanitizer covers + // every slot this function recurses into (incl. contains / propertyNames / + // additionalItems, which coerceSchemaNumericFields does not visit). + if ((NUMERIC_SCHEMA_FIELDS as readonly string[]).includes(key)) { + result[key] = coerceNumericString(value); + continue; + } + if (ARRAY_SCHEMA_KEYS.includes(key)) { + const array = coerceIndexedObjectToArray(value); + if (array === null) continue; // drop invalid non-array keyword (e.g. enum: "[MaxDepth]") + result[key] = SCHEMA_ARRAY_OF_SCHEMAS.has(key) + ? array.map((entry) => stripInvalidSchemaConstructs(entry)) + : array; + continue; + } + if (SCHEMA_SLOT_KEYS.includes(key)) { + // Boolean schemas are valid in JSON Schema (e.g. `additionalProperties: false` + // locks down the object); coercing to {} would silently allow extras and + // invite the model to hallucinate arguments. Only placeholder strings + // (e.g. "[MaxDepth]") get replaced with the permissive {}. + if (isPlainObject(value) || Array.isArray(value)) { + result[key] = stripInvalidSchemaConstructs(value); + } else if (typeof value === "boolean") { + result[key] = value; + } else if (isSchemaPlaceholder(value)) { + result[key] = {}; + } else { + result[key] = value; + } + continue; + } + if (key === "const") { + if (isSchemaPlaceholder(value)) continue; + result[key] = value; + continue; + } + if (key === "properties" && isPlainObject(value)) { + const properties: JsonRecord = {}; + for (const [propName, propSchema] of Object.entries(value)) { + // Same boolean-preservation rule as SCHEMA_SLOT_KEYS above: + // `{ properties: { onlyAdminCanSet: false } }` is a valid permission + // gate and must not be silently turned into the permissive {}. + if (isPlainObject(propSchema) || Array.isArray(propSchema)) { + properties[propName] = stripInvalidSchemaConstructs(propSchema); + } else if (typeof propSchema === "boolean") { + properties[propName] = propSchema; + } else if (isSchemaPlaceholder(propSchema)) { + properties[propName] = {}; + } else { + properties[propName] = propSchema; + } + } + result[key] = properties; + continue; + } + if ( + (key === "$defs" || + key === "definitions" || + key === "patternProperties" || + key === "dependentSchemas") && + isPlainObject(value) + ) { + const defs: JsonRecord = {}; + for (const [defName, defSchema] of Object.entries(value)) { + defs[defName] = stripInvalidSchemaConstructs(defSchema); + } + result[key] = defs; + continue; + } + // Placeholders are only coerced to {} in subschema-expecting positions + // (handled in the branches above). A placeholder in a scalar annotation + // keyword (description / title / pattern / format) must stay scalar — + // turning it into {} is itself invalid draft-2020-12 and would re-trigger + // the very 400 this sanitizer prevents. + result[key] = + isPlainObject(value) || Array.isArray(value) ? stripInvalidSchemaConstructs(value) : value; + } + return result; +} + +export function sanitizeClaudeToolSchema(schema: unknown): unknown { + // stripInvalidSchemaConstructs now also coerces numeric-string constraints, so + // it is the single pass for the Claude path. We deliberately do NOT compose + // coerceSchemaNumericFields: it strips the valid `default` keyword (Fix #1782, + // a translator concern) which on the native / passthrough surface would + // silently alter tool schemas that were previously forwarded verbatim. + return stripInvalidSchemaConstructs(schema); +} + +export function sanitizeClaudeToolSchemas(tools: unknown): unknown { + if (!Array.isArray(tools)) return tools; + return tools.map((tool) => { + if (!isPlainObject(tool) || tool.input_schema === undefined) return tool; + return { ...tool, input_schema: sanitizeClaudeToolSchema(tool.input_schema) }; + }); +} diff --git a/tests/unit/claude-oauth-tool-cloak.test.ts b/tests/unit/claude-oauth-tool-cloak.test.ts new file mode 100644 index 0000000000..cdf1b25420 --- /dev/null +++ b/tests/unit/claude-oauth-tool-cloak.test.ts @@ -0,0 +1,272 @@ +/** + * Native Claude OAuth tool cloak + schema sanitizer. + * + * Anthropic's first-party Messages API rejects native-Claude-OAuth requests + * that carry (a) invalid tool input_schemas (truncation placeholders / non-array + * keywords) or (b) tool names it fingerprints as a third-party agent harness — + * both surfaced as a misleading `400 out of extra usage` placeholder. These + * tests cover the request-side sanitizer + name cloak; the response side is + * reversed via the existing per-request _toolNameMap. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + cloakThirdPartyToolNames, + needsThirdPartyCloak, +} from "../../open-sse/services/claudeCodeToolRemapper.ts"; +import { + sanitizeClaudeToolSchema, + sanitizeClaudeToolSchemas, +} from "../../open-sse/translator/helpers/schemaCoercion.ts"; + +type AnyRecord = Record; +const schemaOf = (tools: unknown, i = 0): AnyRecord => + ((tools as AnyRecord[])[i].input_schema as AnyRecord); + +describe("sanitizeClaudeToolSchemas", () => { + it("drops a non-array enum placeholder", () => { + const tools = [ + { name: "x", input_schema: { type: "object", properties: { m: { type: "string", enum: "[MaxDepth]" } } } }, + ]; + const props = (schemaOf(sanitizeClaudeToolSchemas(tools)).properties as AnyRecord).m as AnyRecord; + assert.equal("enum" in props, false); + }); + + it("coerces an index-keyed object enum into an array", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + properties: { a: { type: "string", enum: { "0": "x", "1": "y" } } }, + }) as AnyRecord; + assert.deepEqual(((s.properties as AnyRecord).a as AnyRecord).enum, ["x", "y"]); + }); + + it("replaces a placeholder property value with a permissive schema", () => { + const s = sanitizeClaudeToolSchema({ type: "object", properties: { a: "[MaxDepth]" } }) as AnyRecord; + assert.deepEqual((s.properties as AnyRecord).a, {}); + }); + + it("leaves a valid schema intact", () => { + const input = { type: "object", properties: { a: { type: "string" } }, required: ["a"] }; + assert.deepEqual(sanitizeClaudeToolSchema(input), input); + }); +}); + +describe("cloakThirdPartyToolNames", () => { + it("aliases a blacklisted name and tracks the reverse map", () => { + const body: AnyRecord = { tools: [{ name: "mixture_of_agents" }] }; + cloakThirdPartyToolNames(body); + assert.equal((body.tools as AnyRecord[])[0].name, "MixtureOfAgents"); + assert.equal((body._toolNameMap as Map).get("MixtureOfAgents"), "mixture_of_agents"); + }); + + it("maps known harness names to Claude Code canonical names", () => { + const body: AnyRecord = { tools: [{ name: "read_file" }, { name: "write_file" }, { name: "terminal" }] }; + cloakThirdPartyToolNames(body); + assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["Read", "Write", "Bash"]); + }); + + it("PascalCases unmapped snake_case names", () => { + const body: AnyRecord = { tools: [{ name: "honcho_profile" }, { name: "lcm_expand_query" }] }; + cloakThirdPartyToolNames(body); + assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["HonchoProfile", "LcmExpandQuery"]); + }); + + it("leaves genuine Claude Code tool names untouched", () => { + const body: AnyRecord = { tools: [{ name: "Bash" }, { name: "Read" }, { name: "TodoWrite" }] }; + cloakThirdPartyToolNames(body); + assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["Bash", "Read", "TodoWrite"]); + assert.equal((body._toolNameMap as Map | undefined)?.size ?? 0, 0); + }); + + it("dedupes canonical-name collisions", () => { + const body: AnyRecord = { tools: [{ name: "search_files" }, { name: "grep_search" }] }; + cloakThirdPartyToolNames(body); + assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["Grep", "Grep2"]); + }); + + it("remaps tool_use blocks in message history consistently", () => { + const body: AnyRecord = { + tools: [{ name: "mixture_of_agents" }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name: "mixture_of_agents" }] }], + }; + cloakThirdPartyToolNames(body); + const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0]; + assert.equal(block.name, "MixtureOfAgents"); + }); + + it("does not leak _toolNameMap into the serialized request body", () => { + const body: AnyRecord = { tools: [{ name: "mixture_of_agents" }] }; + cloakThirdPartyToolNames(body); + assert.equal(JSON.stringify(body).includes("_toolNameMap"), false); + }); + + it("needsThirdPartyCloak only flags non-Claude-Code names", () => { + assert.equal(needsThirdPartyCloak("Bash"), false); + assert.equal(needsThirdPartyCloak("TodoWrite"), false); + assert.equal(needsThirdPartyCloak("read_file"), true); + assert.equal(needsThirdPartyCloak("mixture_of_agents"), true); + }); +}); + +describe("sanitizeClaudeToolSchemas — boolean schema preservation", () => { + it("preserves additionalProperties: false (canonical lock-down)", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + }) as AnyRecord; + assert.equal(s.additionalProperties, false); + }); + + it("preserves additionalProperties: true", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + additionalProperties: true, + }) as AnyRecord; + assert.equal(s.additionalProperties, true); + }); + + it("preserves boolean property schemas under properties", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + properties: { allowed: true, denied: false }, + }) as AnyRecord; + const props = s.properties as AnyRecord; + assert.equal(props.allowed, true); + assert.equal(props.denied, false); + }); + + it("preserves boolean unevaluatedProperties", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + unevaluatedProperties: false, + }) as AnyRecord; + assert.equal(s.unevaluatedProperties, false); + }); + + it("still replaces a placeholder string in a slot key with permissive {}", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + additionalProperties: "[MaxDepth]", + }) as AnyRecord; + assert.deepEqual(s.additionalProperties, {}); + }); +}); + +describe("cloakThirdPartyToolNames — defensive null guards", () => { + it("tolerates null/undefined entries in tools[]", () => { + const body: AnyRecord = { + tools: [null, { name: "read_file" }, undefined, { name: "Bash" }], + }; + cloakThirdPartyToolNames(body); + const names = (body.tools as Array).map((t) => t?.name); + assert.deepEqual(names, [undefined, "Read", undefined, "Bash"]); + }); + + it("tolerates null/undefined entries in messages[]", () => { + const body: AnyRecord = { + tools: [{ name: "read_file" }], + messages: [ + null, + { role: "assistant", content: [{ type: "tool_use", name: "read_file" }] }, + undefined, + ], + }; + cloakThirdPartyToolNames(body); + const block = ( + (body.messages as Array)[1].content as Array + )[0]; + 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"]); + }); +}); + +describe("review fixes — schema sanitizer scalar / default / numeric", () => { + it("keeps a placeholder in a scalar annotation keyword as a scalar (not {})", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + description: "[Object]", + properties: { a: { type: "string", description: "[Truncated]" } }, + }) as AnyRecord; + assert.equal(s.description, "[Object]"); + assert.equal(((s.properties as AnyRecord).a as AnyRecord).description, "[Truncated]"); + }); + + it("preserves the valid `default` keyword on the Claude path", () => { + const s = sanitizeClaudeToolSchema({ + type: "object", + properties: { mode: { type: "string", default: "replace" }, all: { type: "boolean", default: false } }, + }) as AnyRecord; + const p = s.properties as AnyRecord; + assert.equal((p.mode as AnyRecord).default, "replace"); + assert.equal((p.all as AnyRecord).default, false); + }); + + it("coerces numeric-string constraints inside contains (not only items)", () => { + const s = sanitizeClaudeToolSchema({ + type: "array", + contains: { type: "object", properties: { n: { type: "integer", minimum: "5" } } }, + }) as AnyRecord; + const n = ((s.contains as AnyRecord).properties as AnyRecord).n as AnyRecord; + assert.equal(n.minimum, 5); + }); + + it("still coerces a placeholder to {} in a real subschema slot", () => { + const s = sanitizeClaudeToolSchema({ type: "object", additionalProperties: "[MaxDepth]" }) as AnyRecord; + assert.deepEqual(s.additionalProperties, {}); + }); +}); + +describe("review fixes — established aliases + kill-switch", () => { + it("uses the established Claude Code aliases on the cloak path", () => { + const body: AnyRecord = { + tools: [{ name: "subagents" }, { name: "session_status" }, { name: "webfetch" }, { name: "todowrite" }], + }; + cloakThirdPartyToolNames(body); + assert.deepEqual( + (body.tools as AnyRecord[]).map((t) => t.name), + ["SubDispatch", "CheckStatus", "WebFetch", "TodoWrite"] + ); + }); + + it("CLAUDE_DISABLE_TOOL_NAME_CLOAK=true disables the cloak at the function level", () => { + const prev = process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK; + process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK = "true"; + try { + const body: AnyRecord = { tools: [{ name: "mixture_of_agents" }] }; + const map = cloakThirdPartyToolNames(body); + assert.equal((body.tools as AnyRecord[])[0].name, "mixture_of_agents"); + assert.equal(map.size, 0); + } finally { + if (prev === undefined) delete process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK; + else process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK = prev; + } + }); +}); diff --git a/tests/unit/cliproxyapi-executor.test.ts b/tests/unit/cliproxyapi-executor.test.ts index c5f1aab86c..3db932cfc1 100644 --- a/tests/unit/cliproxyapi-executor.test.ts +++ b/tests/unit/cliproxyapi-executor.test.ts @@ -457,14 +457,18 @@ describe("CliproxyapiExecutor", () => { ); }); - it("does not rewrite non-mcp_ tool names", () => { + it("cloaks non-mcp third-party tool names to PascalCase (fingerprint defense, PR #2943)", () => { const exec = new CliproxyapiExecutor(); const body = anthropicBodyWithTools([ { name: "my_tool", description: "My tool", input_schema: {} }, ]); const result = exec.transformRequest("claude-opus-4-7", body, true, {}); const toolName = (result.tools as Array<{ name: string }>)[0].name; - assert.equal(toolName, "my_tool"); + // Non-Claude-Code tool names are now cloaked (my_tool -> MyTool) so Anthropic + // does not fingerprint the third-party harness; restored on the response via + // the non-enumerable _toolNameMap. + assert.equal(toolName, "MyTool"); + assert.equal((result._toolNameMap as Map).get("MyTool"), "my_tool"); }); it("rewrites mcp_* tool_use names in assistant message history", () => {