fix(claude): preserve signed thinking turns during obfuscation (#8629)

Validated in local merge-train T6 (ungrouped batch 1)
This commit is contained in:
Éder Costa
2026-08-06 05:24:13 -03:00
committed by GitHub
parent 011a404684
commit aa7b391386
4 changed files with 72 additions and 6 deletions

View File

@@ -87,9 +87,18 @@ export function obfuscateInBody(body: Record<string, unknown>): void {
if (typeof content === "string") {
msg.content = obfuscateSensitiveWords(content);
} else if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
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<Record<string, unknown>>;
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);
}
}
}
}

View File

@@ -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<Record<string, unknown>>) {
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<Record<string, unknown>>;
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);
}
}
}
}

View File

@@ -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.");
});

View File

@@ -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
// ────────────────────────────────────────────────────────────────────────────