Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions src/__tests__/proxy-tool-blocking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ function createTestApp() {
return app
}

async function sendRequest(app: any, stream: boolean) {
async function sendRequest(app: any, stream: boolean, headers: Record<string, string> = {}) {
capturedQueryParams = null
const response = await app.fetch(new Request("http://localhost/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 128,
Expand Down Expand Up @@ -152,6 +152,57 @@ describe("Tool blocking: normal mode (non-passthrough)", () => {
})
})

// A Claude Code CLI client drives its own tool loop. server.ts resolves tool
// config and passthrough from `pipelineCtx.*` — never from the adapter methods
// — so with no registry entry for "claude-code" the request kept the
// createRequestContext defaults: `blockedTools: []` and `passthrough:
// undefined`. The SDK subprocess then had its own Read/Write/Bash available
// while the client executed the same tool_use blocks locally, so a side effect
// could happen twice (#735).
//
// These go through the HTTP layer on purpose: transform-parity tests assert the
// transform's VALUES, and every one of them passed while this was broken. The
// defect was the wiring between the registry and the request, which only a
// server-level assertion can see.
describe("Tool blocking: claude-code adapter (#735)", () => {
let savedAgent: string | undefined
beforeEach(() => {
// Default install: no passthrough opt-in, and no default-agent override
// (which would reroute the ambiguous claude-cli User-Agent elsewhere).
delete process.env.CLAUDE_PROXY_PASSTHROUGH
delete process.env.MERIDIAN_PASSTHROUGH
savedAgent = process.env.MERIDIAN_DEFAULT_AGENT
delete process.env.MERIDIAN_DEFAULT_AGENT
})
afterEach(() => {
if (savedAgent !== undefined) process.env.MERIDIAN_DEFAULT_AGENT = savedAgent
else delete process.env.MERIDIAN_DEFAULT_AGENT
})

const CLAUDE_CLI_UA = { "user-agent": "claude-cli/1.0.60 (external)" }

it("blocks the SDK's built-in tools for a claude-cli client (non-stream)", async () => {
const app = createTestApp()
const params = await sendRequest(app, false, CLAUDE_CLI_UA)
assertAllToolsBlocked(params, "claude-code/non-stream")
})

it("blocks the SDK's built-in tools for a claude-cli client (stream)", async () => {
const app = createTestApp()
const params = await sendRequest(app, true, CLAUDE_CLI_UA)
assertAllToolsBlocked(params, "claude-code/stream")
})

it("does not leave disallowedTools empty, which is the actual regression", async () => {
// Stated separately from assertAllToolsBlocked: an empty list is the exact
// broken state, and a future change that empties it should fail on a test
// whose name says so.
const app = createTestApp()
const params = await sendRequest(app, false, CLAUDE_CLI_UA)
expect((params?.options?.disallowedTools || []).length).toBeGreaterThan(0)
})
})

describe("Tool blocking: passthrough mode", () => {
beforeEach(() => {
process.env.CLAUDE_PROXY_PASSTHROUGH = "1"
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/transform-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { droidAdapter } from "../proxy/adapters/droid"
import { piAdapter } from "../proxy/adapters/pi"
import { forgeCodeAdapter } from "../proxy/adapters/forgecode"
import { passthroughAdapter } from "../proxy/adapters/passthrough"
import { claudeCodeTransforms } from "../proxy/transforms/claudecode"
import { claudeCodeAdapter } from "../proxy/adapters/claudecode"
import { getAdapterTransforms } from "../proxy/transforms/registry"

function makeCtx(adapter: string, body: any = {}) {
return createRequestContext({
Expand Down Expand Up @@ -229,3 +232,72 @@ describe("Passthrough (LiteLLM) transform parity", () => {
expect([...ctx.blockedTools]).toEqual([...passthroughAdapter.getBlockedBuiltinTools()])
})
})

// The Claude Code CLI drives its own tool loop. If registry.ts has no entry for
// adapter "claude-code" (or the transform's `adapters` array omits it), the SDK
// runs the client's task internally on the proxy host with built-ins unblocked
// — every Bash/Edit executes twice, once per side. Same class as #546.
describe("Claude Code transform parity", () => {
let savedMP: string | undefined
let savedCP: string | undefined
beforeEach(() => {
savedMP = process.env.MERIDIAN_PASSTHROUGH
savedCP = process.env.CLAUDE_PROXY_PASSTHROUGH
delete process.env.MERIDIAN_PASSTHROUGH
delete process.env.CLAUDE_PROXY_PASSTHROUGH
})
afterEach(() => {
if (savedMP !== undefined) process.env.MERIDIAN_PASSTHROUGH = savedMP
else delete process.env.MERIDIAN_PASSTHROUGH
if (savedCP !== undefined) process.env.CLAUDE_PROXY_PASSTHROUGH = savedCP
else delete process.env.CLAUDE_PROXY_PASSTHROUGH
})

it("is registered under the adapter's own name", () => {
expect(getAdapterTransforms(claudeCodeAdapter.name)).toBe(claudeCodeTransforms)
})

it("matches blockedTools", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect([...ctx.blockedTools]).toEqual([...claudeCodeAdapter.getBlockedBuiltinTools()])
expect(ctx.blockedTools.length).toBeGreaterThan(0)
})

it("matches incompatibleTools and allowedMcpTools", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect([...ctx.incompatibleTools]).toEqual([...claudeCodeAdapter.getAgentIncompatibleTools()])
expect([...ctx.allowedMcpTools]).toEqual([...claudeCodeAdapter.getAllowedMcpTools()])
})

it("matches coreToolNames (PascalCase, not OpenCode's lowercase)", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect([...ctx.coreToolNames!]).toEqual([...claudeCodeAdapter.getCoreToolNames!()])
})

it("matches passthrough (default on)", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.passthrough).toBe(claudeCodeAdapter.usesPassthrough!())
expect(ctx.passthrough).toBe(true)
})

it("matches passthrough (env off → false)", () => {
process.env.MERIDIAN_PASSTHROUGH = "0"
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.passthrough).toBe(claudeCodeAdapter.usesPassthrough!())
expect(ctx.passthrough).toBe(false)
})

it("matches supportsThinking and shouldTrackFileChanges", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.supportsThinking).toBe(claudeCodeAdapter.supportsThinking!())
expect(ctx.shouldTrackFileChanges).toBe(claudeCodeAdapter.shouldTrackFileChanges!())
})

it("matches file change extraction for Edit and Bash", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.extractFileChangesFromToolUse!("Edit", { file_path: "/a.ts" }))
.toEqual(claudeCodeAdapter.extractFileChangesFromToolUse!("Edit", { file_path: "/a.ts" }))
expect(ctx.extractFileChangesFromToolUse!("Bash", { command: "echo hi > /tmp/a" }))
.toEqual(claudeCodeAdapter.extractFileChangesFromToolUse!("Bash", { command: "echo hi > /tmp/a" }))
})
})
58 changes: 58 additions & 0 deletions src/proxy/transforms/claudecode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Transform, RequestContext } from "../transform"
import { extractFileChangesFromBash, type FileChange } from "../fileChanges"
import { BLOCKED_BUILTIN_TOOLS, CLAUDE_CODE_ONLY_TOOLS, ALLOWED_MCP_TOOLS } from "../tools"
import { resolvePassthrough } from "../../env"

/**
* Claude Code transform — supplies the SDK tool config at request time.
*
* server.ts reads `pipelineCtx.*`, never the adapter methods, so without an
* entry here a Claude Code client gets the createRequestContext defaults:
* `blockedTools: []` and `passthrough: undefined`. That means the SDK
* subprocess runs the client's task with its own built-in Read/Write/Bash on
* the proxy host while the client executes the same tool calls locally —
* every side effect happens twice (#546, same failure mode as OpenCode's).
*
* Values mirror claudeCodeAdapter (adapters/claudecode.ts); the parity tests
* hold the two in sync. Core tool names are PascalCase here — Claude Code's
* toolkit is Read/Write/Edit/Bash/Glob/Grep, not OpenCode's lowercase names.
*/
export const claudeCodeTransforms: Transform[] = [
{
name: "claudecode-core",
adapters: ["claude-code"],

onRequest(ctx: RequestContext): RequestContext {
const extractFileChangesFromToolUse = (toolName: string, toolInput: unknown): FileChange[] => {
const input = toolInput as Record<string, unknown> | null | undefined
const filePath = input?.file_path ?? input?.filePath ?? input?.path
const lowerName = toolName.toLowerCase()
if (lowerName === "write" && filePath) {
return [{ operation: "wrote", path: String(filePath) }]
}
if ((lowerName === "edit" || lowerName === "multiedit") && filePath) {
return [{ operation: "edited", path: String(filePath) }]
}
if (lowerName === "bash" && input?.command) {
return extractFileChangesFromBash(String(input.command))
}
return []
}

return {
...ctx,
blockedTools: BLOCKED_BUILTIN_TOOLS,
incompatibleTools: CLAUDE_CODE_ONLY_TOOLS,
allowedMcpTools: ALLOWED_MCP_TOOLS,
coreToolNames: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
// Claude Code owns tool execution client-side. Mirrors
// claudeCodeAdapter.usesPassthrough().
passthrough: resolvePassthrough(true),
supportsThinking: true,
// Claude Code surfaces its own file edits; don't duplicate them.
shouldTrackFileChanges: false,
extractFileChangesFromToolUse,
}
},
},
]
5 changes: 5 additions & 0 deletions src/proxy/transforms/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { forgeCodeTransforms } from "./forgecode"
import { passthroughTransforms } from "./passthrough"
import { cherryTransforms } from "./cherry"
import { codexTransforms } from "./codex"
import { claudeCodeTransforms } from "./claudecode"

const ADAPTER_TRANSFORMS: Record<string, readonly Transform[]> = {
opencode: openCodeTransforms,
Expand All @@ -16,6 +17,10 @@ const ADAPTER_TRANSFORMS: Record<string, readonly Transform[]> = {
forgecode: forgeCodeTransforms,
passthrough: passthroughTransforms,
cherry: cherryTransforms,
// Keyed by adapter.name ("claude-code", not "claudecode"). Without this the
// Claude Code CLI falls through to the empty default — built-ins unblocked,
// passthrough off — and the SDK re-executes every tool call on the proxy host.
"claude-code": claudeCodeTransforms,
// The OpenAI-compatible endpoint reuses OpenCode's transforms verbatim so
// tool/passthrough behaviour is identical; only the preset default differs
// (see sdkFeatures.ADAPTER_DEFAULTS.openai).
Expand Down
Loading