fix(claude): sanitize tool schemas + cloak third-party tool names on native Claude OAuth

Native Claude OAuth (claude->claude passthrough) forwards client tool
definitions verbatim. Anthropic's first-party Messages API then rejects:
  - invalid tool input_schemas (deep-truncation placeholders such as
    `enum: "[MaxDepth]"`, or index-keyed objects where arrays are required), and
  - tool names it fingerprints as a third-party agent harness (specific
    blacklisted names like `mixture_of_agents`, or a large enough set of
    recognizable snake_case agent tool names),
both surfaced as a misleading `400 You're out of extra usage` placeholder
(the SSE stream is refused — not a real billing event). The same request
succeeds on translator-backed providers (OpenAI/Codex), which already sanitize
and re-shape tool payloads — so the gap is specific to the native passthrough.

Adds the missing guards on the native Claude OAuth path (executors/base.ts):
  - sanitizeClaudeToolSchemas(): coerce/drop invalid draft-2020-12 constructs
    (non-array enum/required/anyOf/..., placeholder schema slots -> {}).
  - cloakThirdPartyToolNames(): deterministically alias non-Claude-Code tool
    names (Claude Code canonical mapping where one exists, else PascalCase),
    tracked in the existing per-request _toolNameMap so remapToolNamesInResponse
    restores the caller's original names. Opt out via
    CLAUDE_DISABLE_TOOL_NAME_CLOAK=true.

Genuine Claude Code tool names (PascalCase) and already-valid schemas are
left untouched, so existing first-party traffic is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NomenAK
2026-05-30 12:54:43 +00:00
parent 53c8ffcc34
commit 7e7faad079
4 changed files with 357 additions and 1 deletions

View File

@@ -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,15 @@ 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.
if (process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK !== "true") {
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.

View File

@@ -144,3 +144,121 @@ 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<string>(Object.values(TOOL_RENAME_MAP));
const HARNESS_CANONICAL_MAP: Record<string, string> = {
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 function cloakThirdPartyToolNames(body: Record<string, unknown>): Map<string, string> {
const tools = body.tools as Array<Record<string, unknown>> | undefined;
const used = new Set<string>();
if (Array.isArray(tools)) {
for (const tool of tools) {
if (typeof tool.name === "string") used.add(tool.name);
}
}
const existingMap =
body._toolNameMap instanceof Map ? (body._toolNameMap as Map<string, string>) : 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<string, string> | null = existingMap;
const assigned = new Map<string, string>(); // original -> alias
const aliasFor = (original: string): string => {
const existing = assigned.get(original);
if (existing) return existing;
const base = 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;
};
if (Array.isArray(tools)) {
for (const tool of tools) {
if (typeof tool.name === "string" && needsThirdPartyCloak(tool.name)) {
tool.name = aliasFor(tool.name);
}
}
}
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const message of messages) {
const content = message.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block?.type === "tool_use" && typeof block.name === "string" && needsThirdPartyCloak(block.name)) {
block.name = aliasFor(block.name);
}
}
}
}
const toolChoice = body.tool_choice as Record<string, unknown> | undefined;
if (toolChoice?.type === "tool" && typeof toolChoice.name === "string" && needsThirdPartyCloak(toolChoice.name)) {
toolChoice.name = aliasFor(toolChoice.name);
}
return nameMap ?? new Map<string, string>();
}

View File

@@ -232,3 +232,122 @@ 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)) {
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)) {
result[key] =
isPlainObject(value) || Array.isArray(value) ? stripInvalidSchemaConstructs(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)) {
properties[propName] = isPlainObject(propSchema)
? stripInvalidSchemaConstructs(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;
}
if (isSchemaPlaceholder(value)) {
result[key] = {};
continue;
}
result[key] =
isPlainObject(value) || Array.isArray(value) ? stripInvalidSchemaConstructs(value) : value;
}
return result;
}
export function sanitizeClaudeToolSchema(schema: unknown): unknown {
return stripInvalidSchemaConstructs(coerceSchemaNumericFields(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) };
});
}

View File

@@ -0,0 +1,109 @@
/**
* 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<string, unknown>;
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<string, string>).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<string, string> | 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);
});
});