fix(passthrough): strip SDK tool catalog and CLAUDE.md from upstream payload#490
Merged
Conversation
…payload albe-jj diagnosed in #489 that passthrough requests include the SDK's ~25k-token built-in tool catalog (Read/Write/Edit/Bash/Glob/Grep/etc.) on every call. PR #469 fixed the system-prompt half of #408 but not the tool-catalog half. This is the other half. While auditing the rest of the passthrough payload — explicitly asked for in this PR's scope — also found a second leak: when `claudeMd: "off"` (the passthrough default) server.ts produces `settingSources = []`, but query.ts's `length > 0` gate dropped the empty array, so the SDK never emitted `--setting-sources=` and claude-code's subprocess fell back to its built-in default (load user/project/local) — silently slurping CLAUDE.md from the proxy host's cwd into the system prompt. End-to-end measurement (albe-jj's repro, claude-haiku-4-5): Baseline (main): 25,856 tokens After tools: [] only: 2,700 tokens After tools: [] + settingSources: []: 322 tokens A 98.8% reduction. The post-fix response is generic chat ("Hi! Nice to meet you. I'm Claude, an AI assistant.") — no Claude Code preset, no CLAUDE.md context, exactly what passthrough should be. Three changes in src/proxy/query.ts: 1. tools: [] in the passthrough branch. Distinct from `disallowedTools` which only blocks invocation at runtime; `tools: []` actually elides the catalog from the upstream payload. 2. settingSources: [] in the passthrough branch. The lower-down spread block still wins when the user opts in via claudeMd: "project" or "full" because object spread keeps the last assignment — so per-feature customization still works. 3. Defensive `if (codeSystemPrompt === false) return { systemPrompt: "" }` in resolveSystemPrompt. Forces explicit empty rather than undefined so the SDK can't reintroduce the preset via a default-fallback path. Belt-and-suspenders, very low impact in practice. Tests: 5 new in query.test.ts (catalog stripping, MCP coexistence, non-passthrough preservation, settingSources empty-array survival, defensive empty-string). 2 existing tests updated to reflect the defensive behavior (systemPrompt: "" instead of undefined). Full suite 1727/0. Diagnosis and proposed fix: @albe-jj in #489.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #489.
Root cause (and what #469 left behind)
@albe-jj's diagnosis in #489 is correct: PR #469 fixed the system prompt half of #408 —
codeSystemPrompt: falseis now respected for the passthrough adapter. But the upstream request to Claude still includes the SDK's built-in tool catalog (~21–25k tokens of Read/Write/Edit/Bash/Glob/Grep/etc.).disallowedToolsonly blocks tool invocation at runtime; it does not remove the definitions from the upstream payload.While auditing the rest of the passthrough payload (per maintainer ask before merging this), I also found a second leak: when
claudeMd: "off"(the passthrough adapter's default),server.tsproducessettingSources = [], butquery.ts'slength > 0gate silently dropped the empty array. The SDK never emitted--setting-sources=, so the claude-code subprocess fell back to its built-in default (loaduser/project/local) and slurped CLAUDE.md from the proxy host's cwd into the system prompt. The smoking gun in my E2E run was the model's response: "I've got the context from your CLAUDE.md file" — even withcodeSystemPrompt: false.Fix
Three coordinated changes in
src/proxy/query.ts:Plus a defensive return in
resolveSystemPrompt:The lower-down
settingSources && settingSources.length > 0spread block still wins when the user opts intoclaudeMd: "project"or"full"— object spread keeps the last assignment, so per-feature customization continues to work.Scope: passthrough only. Coding-agent adapters (OpenCode, Crush, ForgeCode, Pi, Droid, Claude Code) are untouched. Their CLAUDE.md-loading behavior is preserved (it's intentional for them — coding agents want project context).
End-to-end evidence
@albe-jj's repro, claude-haiku-4-5, fresh proxy each time:
cache_creation_input_tokensinput_tokensmain)tools: []onlyThe intermediate row exposes exactly the second leak — even after the catalog is gone, CLAUDE.md content is still bleeding in. The full fix produces a generic-chat response, no Claude-Code preset, no project context.
Tests
5 new in
src/__tests__/query.test.ts:tools: []is set in passthrough modetools: []survives even whenpassthroughMcpis present (catalog stripped, MCP tools still allowed)toolsstays undefined in non-passthrough mode (catalog must remain available for OpenCode/Crush/etc.)settingSources: []is set in passthrough modesettingSourcesfrom caller (e.g.claudeMd: "project") still wins over the passthrough default2 existing tests updated to reflect the defensive empty-string in
resolveSystemPrompt(systemPrompt: ""instead ofundefined). Full suite: 1727 / 0. Typecheck clean. Build clean.Risk
claudeMd: "project"orclaudeMd: "full"in/settings. We should call this out in the release notes.systemPrompt: ""change applies to any adapter, but only kicks in whencodeSystemPrompt: falseAND there's nothing to append — an edge case that previously fell back to undefined. Strictly more deterministic.Out of scope (follow-ups, not blocking this PR)
claudeMd: "off"for them too, claude-code's default loader still runs. Fixing that is a behavior change that needs its own discussion (some users may rely on the current implicit loading). Filed mentally for a separate audit.tools: []for chat-client adapters (Cherry Studio, etc.). Different shape — chat clients want some built-ins enabled (WebSearch). Their tool blocking lives ingetAgentIncompatibleTools()and is intentional.Credit
Diagnosis, exact root-cause analysis, and the precise code change for the primary fix: @albe-jj in #489. The settingSources leak and the defensive empty-string were caught during the additional audit & E2E pass.
Merge strategy
Squash merge. Single commit. Authorize when ready.