Repository navigation
fix(claude): sanitize tool schemas + cloak third-party tool names on native Claude OAuth #2943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e7faad
3b2d075
23dad7d
a1e0bc7
dd6104e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<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 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> { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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<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>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<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; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+266
to
+273
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly, add a defensive check to ensure
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, thanks — addressed in 3b2d075. Booleans preserved verbatim (placeholders still get the permissive
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, thanks (3b2d075). |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const messages = body.messages as Array<Record<string, unknown>> | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (Array.isArray(messages)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| body.messages = messages.map((message) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const content = message?.content as Array<Record<string, unknown>> | 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+275
to
+294
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a defensive check to ensure
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, thanks — addressed in 3b2d075. Booleans preserved verbatim (placeholders still get the permissive
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, thanks. Also added the \ optional-chain on the same loop (3b2d075). |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const toolChoice = body.tool_choice as Record<string, unknown> | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent potential runtime
TypeErrors if thetoolsarray containsnullorundefinedvalues, add a defensive check to ensuretoolis defined before accessing its properties.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, thanks — addressed in 3b2d075. Booleans preserved verbatim (placeholders still get the permissive
{}), null-guards added on tools[]/messages[], andCLAUDE_DISABLE_TOOL_NAME_CLOAKis now documented. Regression tests cover all of the above.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, thanks (3b2d075).