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
15 changes: 12 additions & 3 deletions open-sse/services/claudeCodeObfuscation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
14 changes: 11 additions & 3 deletions open-sse/services/systemTransforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/claude-code-obfuscation.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
19 changes: 19 additions & 0 deletions tests/unit/system-transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ────────────────────────────────────────────────────────────────────────────
Expand Down
Loading