diff --git a/open-sse/services/claudeCodeObfuscation.ts b/open-sse/services/claudeCodeObfuscation.ts index 3a8423cbf1..92c7c86d6e 100644 --- a/open-sse/services/claudeCodeObfuscation.ts +++ b/open-sse/services/claudeCodeObfuscation.ts @@ -87,9 +87,18 @@ export function obfuscateInBody(body: Record): void { if (typeof content === "string") { msg.content = obfuscateSensitiveWords(content); } else if (Array.isArray(content)) { - for (const block of content as Array>) { - if (typeof block.text === "string") { - block.text = obfuscateSensitiveWords(block.text); + // Anthropic verifies a signature over a thinking turn. Mutating a text + // sibling in that same turn invalidates it and makes the next request + // fail with `Invalid signature in thinking block`. + const blocks = content as Array>; + const hasSignedThinking = blocks.some( + (block) => block?.type === "thinking" || block?.type === "redacted_thinking" + ); + if (!hasSignedThinking) { + for (const block of blocks) { + if (typeof block.text === "string") { + block.text = obfuscateSensitiveWords(block.text); + } } } } diff --git a/open-sse/services/systemTransforms.ts b/open-sse/services/systemTransforms.ts index 813bd5350f..e7542619e3 100644 --- a/open-sse/services/systemTransforms.ts +++ b/open-sse/services/systemTransforms.ts @@ -341,9 +341,17 @@ function applyObfuscateWords(body: RequestBody, op: ObfuscateWordsOp): void { if (typeof content === "string") { msg.content = obfuscateWithList(content, words); } else if (Array.isArray(content)) { - for (const block of content as Array>) { - if (typeof block.text === "string") { - block.text = obfuscateWithList(block.text, words); + // A signed Anthropic thinking turn covers its text siblings too. Leave + // the entire turn byte-for-byte intact so its signature remains valid. + const blocks = content as Array>; + const hasSignedThinking = blocks.some( + (block) => block?.type === "thinking" || block?.type === "redacted_thinking" + ); + if (!hasSignedThinking) { + for (const block of blocks) { + if (typeof block.text === "string") { + block.text = obfuscateWithList(block.text, words); + } } } } diff --git a/tests/unit/claude-code-obfuscation.test.ts b/tests/unit/claude-code-obfuscation.test.ts new file mode 100644 index 0000000000..e426d2113c --- /dev/null +++ b/tests/unit/claude-code-obfuscation.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { obfuscateInBody } = await import("../../open-sse/services/claudeCodeObfuscation.ts"); + +test("obfuscateInBody preserves a turn that carries signed thinking", () => { + const body = { + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "private", signature: "signed-by-anthropic" }, + { type: "text", text: "I used opencode to inspect it." }, + ], + }, + ], + }; + + obfuscateInBody(body); + assert.equal((body.messages[0].content[1] as { text: string }).text, "I used opencode to inspect it."); +}); + +test("obfuscateInBody still obfuscates an unsigned normal text turn", () => { + const body = { + messages: [{ role: "assistant", content: [{ type: "text", text: "I used opencode." }] }], + }; + + obfuscateInBody(body); + assert.notEqual((body.messages[0].content[0] as { text: string }).text, "I used opencode."); +}); diff --git a/tests/unit/system-transforms.test.ts b/tests/unit/system-transforms.test.ts index ab3cbafb4f..cf1f7391e0 100644 --- a/tests/unit/system-transforms.test.ts +++ b/tests/unit/system-transforms.test.ts @@ -145,6 +145,25 @@ test("obfuscate_words with empty list is a no-op", () => { assert.equal((body.system[0] as { text: string }).text, "opencode"); }); +test("obfuscate_words preserves a message turn with signed thinking", () => { + const body = { + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "private", signature: "signed-by-anthropic" }, + { type: "text", text: "opencode remains unchanged" }, + ], + }, + ], + }; + applyTransformPipeline(body, [{ kind: "obfuscate_words", words: ["opencode"] }]); + assert.equal( + (body.messages[0].content[1] as { text: string }).text, + "opencode remains unchanged" + ); +}); + // ──────────────────────────────────────────────────────────────────────────── // Pipeline ordering: drop paragraph then obfuscate what survives // ────────────────────────────────────────────────────────────────────────────