fix(claude): apply tool cloak + schema sanitize on the CLIProxyAPI executor path

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 <noreply@anthropic.com>
This commit is contained in:
NomenAK
2026-05-30 13:59:36 +00:00
parent 3b2d075402
commit 23dad7d93d
3 changed files with 102 additions and 16 deletions

View File

@@ -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<string, string>(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,
});
}
}

View File

@@ -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<string, unknown>): Map<string, string> {
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<string, unknown>,
options?: CloakOptions
): Map<string, string> {
const shouldCloak = (name: string): boolean =>
needsThirdPartyCloak(name) && !(options?.skip ? options.skip(name) : false);
const tools = body.tools as Array<Record<string, unknown>> | undefined;
const used = new Set<string>();
@@ -234,34 +249,46 @@ export function cloakThirdPartyToolNames(body: Record<string, unknown>): Map<str
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)) {
for (const tool of tools) {
if (tool && typeof tool.name === "string" && needsThirdPartyCloak(tool.name)) {
tool.name = aliasFor(tool.name);
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<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const message of messages) {
body.messages = messages.map((message) => {
const content = message?.content as Array<Record<string, unknown>> | 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<string, unknown> | 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<string, string>();

View File

@@ -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"]);
});
});