Skip to content

fix(passthrough): strip SDK tool catalog and CLAUDE.md from upstream payload#490

Merged
rynfar merged 1 commit into
mainfrom
fix/passthrough-tools-catalog
May 6, 2026
Merged

fix(passthrough): strip SDK tool catalog and CLAUDE.md from upstream payload#490
rynfar merged 1 commit into
mainfrom
fix/passthrough-tools-catalog

Conversation

@rynfar
Copy link
Copy Markdown
Owner

@rynfar rynfar commented May 6, 2026

Closes #489.

TL;DR: Passthrough requests were paying ~25,856 tokens of overhead per call. After this PR they pay 322. That's a 98.8% reduction, measured on @albe-jj's exact repro, end-to-end against the live proxy.

Root cause (and what #469 left behind)

@albe-jj's diagnosis in #489 is correct: PR #469 fixed the system prompt half of #408codeSystemPrompt: false is 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.). disallowedTools only 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.ts produces settingSources = [], but query.ts's length > 0 gate silently dropped the empty array. The SDK never emitted --setting-sources=, so the claude-code subprocess fell back to its built-in default (load user/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 with codeSystemPrompt: false.

Fix

Three coordinated changes in src/proxy/query.ts:

...(passthrough
  ? {
      tools: [],              // ← strip ~25k-token catalog
      settingSources: [],     // ← stop CLAUDE.md from auto-loading
      disallowedTools: [...allBlockedTools],
      ...(passthroughMcp ? { allowedTools, mcpServers } : {}),
    }
  : {
      disallowedTools: [...allBlockedTools],
      allowedTools: [...allowedMcpTools],
      mcpServers: { [mcpServerName]: createOpencodeMcpServer() },
    }),

Plus a defensive return in resolveSystemPrompt:

// Forecloses any default-fallback path that would let the preset sneak
// back in. Empty string > undefined for explicit-no-preset signaling.
if (codeSystemPrompt === false) return { systemPrompt: "" }

The lower-down settingSources && settingSources.length > 0 spread block still wins when the user opts into claudeMd: "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:

Stage cache_creation_input_tokens input_tokens Sample response
Baseline (current main) 25,856 10 "Hey! 👋 I'm ready to help with your meridian project (the OpenCode ↔ Claude M…"
After tools: [] only 0 2,700 "Hey! 👋 I'm here and ready to help. I've got the context from your CLAUDE.md file…"
Both fixes applied 0 322 "Hi! 👋 Nice to meet you. I'm Claude, an AI assistant."

The 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 mode
  • tools: [] survives even when passthroughMcp is present (catalog stripped, MCP tools still allowed)
  • tools stays undefined in non-passthrough mode (catalog must remain available for OpenCode/Crush/etc.)
  • settingSources: [] is set in passthrough mode
  • settingSources from caller (e.g. claudeMd: "project") still wins over the passthrough default

2 existing tests updated to reflect the defensive empty-string in resolveSystemPrompt (systemPrompt: "" instead of undefined). Full suite: 1727 / 0. Typecheck clean. Build clean.

Risk

  • Behavior change scoped to passthrough. Any caller relying on the silent CLAUDE.md leak in passthrough requests will see the project context disappear. They can opt back in via claudeMd: "project" or claudeMd: "full" in /settings. We should call this out in the release notes.
  • The defensive systemPrompt: "" change applies to any adapter, but only kicks in when codeSystemPrompt: false AND there's nothing to append — an edge case that previously fell back to undefined. Strictly more deterministic.
  • No change to the auth surface, profile system, or any other adapter.

Out of scope (follow-ups, not blocking this PR)

  • Coding-agent CLAUDE.md leak. OpenCode/Crush/etc. still use the same query.ts gate, so when 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 in getAgentIncompatibleTools() 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.

…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.
@rynfar rynfar merged commit b279fee into main May 6, 2026
3 checks passed
@rynfar rynfar deleted the fix/passthrough-tools-catalog branch May 6, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

# Passthrough sends SDK built-in tool catalog (~21k tokens) to upstream Claude in v 1.42.0

1 participant