From b59ee9a99cdc40ba94f241d8a8cdbde3e367494f Mon Sep 17 00:00:00 2001 From: usr_bin_roygbiv Date: Wed, 15 Jul 2026 23:56:19 -0500 Subject: [PATCH 1/5] fix(ai): redact sensitive credentials from outbound messages --- .../ai/src/providers/transform-messages.ts | 131 +++++++++++++++++- ...ransform-messages-redact-sensitive.test.ts | 105 ++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/transform-messages-redact-sensitive.test.ts diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 0751c4ece..4b85aa45d 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -1,5 +1,14 @@ import { renderDemotedThinking } from "../dialect/demotion"; -import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types"; +import type { + Api, + AssistantMessage, + DeveloperMessage, + Message, + Model, + ToolCall, + ToolResultMessage, + UserMessage, +} from "../types"; import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols"; const enum ToolCallStatus { @@ -286,6 +295,122 @@ function normalizeAnthropicTargetToolCallId( * - Preserves tool call structure (unlike converting to text summaries) * - Injects synthetic "aborted" tool results */ +const SENSITIVE_TOKEN_RE = + /\b(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*]{36,}|sk-ant-[a-zA-Z0-9_*]{36,}|sk-[a-zA-Z0-9_*]{48,})(?![a-zA-Z0-9_*])/g; + +export function redactSensitiveCredentials(text: string): string { + return text.replace(SENSITIVE_TOKEN_RE, (_match, token) => { + if (token.startsWith("gh")) { + return "[github_token_redacted]"; + } + if (token.startsWith("gl")) { + return "[gitlab_token_redacted]"; + } + if (token.startsWith("sk-ant-")) { + return "[anthropic_token_redacted]"; + } + if (token.startsWith("sk")) { + return "[openai_token_redacted]"; + } + return "[token_redacted]"; + }); +} + +function redactSensitiveInObject(val: unknown): unknown { + if (typeof val === "string") { + return redactSensitiveCredentials(val); + } + if (Array.isArray(val)) { + return val.map(redactSensitiveInObject); + } + if (val !== null && typeof val === "object") { + const res: Record = {}; + for (const [k, v] of Object.entries(val)) { + res[k] = redactSensitiveInObject(v); + } + return res; + } + return val; +} + +function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { + return messages.map((msg): Message => { + if (msg.role === "user" || msg.role === "developer") { + const userMsg = msg as UserMessage | DeveloperMessage; + if (typeof userMsg.content === "string") { + const redacted = redactSensitiveCredentials(userMsg.content); + if (redacted === userMsg.content) return msg; + return { ...userMsg, content: redacted } as Message; + } + const contentArray = userMsg.content; + let changed = false; + const content = contentArray.map((block): UserMessage["content"][number] => { + if (block.type === "text") { + const redacted = redactSensitiveCredentials(block.text); + if (redacted !== block.text) { + changed = true; + return { ...block, text: redacted }; + } + } + return block; + }); + return (changed ? { ...userMsg, content } : userMsg) as Message; + } + + if (msg.role === "toolResult") { + const toolResultMsg = msg as ToolResultMessage; + let changed = false; + const content = toolResultMsg.content.map((block): ToolResultMessage["content"][number] => { + if (block.type === "text") { + const redacted = redactSensitiveCredentials(block.text); + if (redacted !== block.text) { + changed = true; + return { ...block, text: redacted }; + } + } + return block; + }); + return (changed ? { ...toolResultMsg, content } : toolResultMsg) as Message; + } + + if (msg.role === "assistant") { + const assistantMsg = msg as AssistantMessage; + let changed = false; + const content = assistantMsg.content.map((block): AssistantMessage["content"][number] => { + if (block.type === "text") { + const redacted = redactSensitiveCredentials(block.text); + if (redacted !== block.text) { + changed = true; + return { ...block, text: redacted }; + } + } else if (block.type === "thinking") { + const redacted = redactSensitiveCredentials(block.thinking); + if (redacted !== block.thinking) { + changed = true; + return { ...block, thinking: redacted }; + } + } else if (block.type === "toolCall") { + if (block.arguments) { + const redactedArgs = redactSensitiveInObject(block.arguments); + if (JSON.stringify(redactedArgs) !== JSON.stringify(block.arguments)) { + changed = true; + const castArgs = + redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs) + ? (redactedArgs as Record) + : undefined; + return { ...block, arguments: castArgs } as AssistantMessage["content"][number]; + } + } + } + return block; + }); + return (changed ? { ...assistantMsg, content } : assistantMsg) as Message; + } + + return msg; + }); +} + export function transformMessages( messages: Message[], model: Model, @@ -294,6 +419,10 @@ export function transformMessages( duplicateToolCallIdSuffixPrefix = "_dup", targetCompat: Model["compat"] = model.compat, ): Message[] { + // Redact sensitive credential-like patterns from all outbound messages + // to prevent security block errors from LLM providers (e.g. invalid_prompt). + messages = redactSensitiveCredentialsInMessages(messages); + // Drop assistant `toolCall` blocks with empty/whitespace `id` or `name` // (and their matched `toolResult` messages) before anything else looks at // the history. Replays of these would 400 every provider — see diff --git a/packages/ai/test/transform-messages-redact-sensitive.test.ts b/packages/ai/test/transform-messages-redact-sensitive.test.ts new file mode 100644 index 000000000..358cd793e --- /dev/null +++ b/packages/ai/test/transform-messages-redact-sensitive.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; +import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; +import type { AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +function makeModel(): Model<"openai-responses"> { + return buildModel({ + api: "openai-responses", + name: "GPT Test", + id: "gpt-test", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + contextWindow: 8192, + maxTokens: 2048, + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); +} + +describe("transformMessages redact sensitive credentials", () => { + it("redacts already-masked and real tokens from outbound messages", () => { + const messages: Message[] = [ + { + role: "user", + content: "Token: gho_************************************", + timestamp: Date.now(), + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I found this key: sk-proj-************************************", + }, + { + type: "toolCall", + id: "call_x", + name: "bash", + arguments: { + command: "echo gho_************************************", + }, + }, + ], + api: "openai-responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "call_x", + toolName: "bash", + content: [{ type: "text", text: "Token is ghp_************************************ inside output" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const transformed = transformMessages(messages, makeModel()); + + // 1. Verify user message is redacted + const userMsg = transformed[0]; + expect(userMsg.role).toBe("user"); + expect(userMsg.content).toBe("Token: [github_token_redacted]"); + + // 2. Verify assistant message text and toolCall arguments are redacted + const assistantMsg = transformed[1]; + expect(assistantMsg.role).toBe("assistant"); + const castAssistantMsg = assistantMsg as AssistantMessage; + const assistantContent = castAssistantMsg.content; + const textBlock = assistantContent[0]; + expect(textBlock.type).toBe("text"); + if (textBlock.type === "text") { + expect(textBlock.text).toBe("I found this key: [openai_token_redacted]"); + } + + const toolCallBlock = assistantContent[1]; + expect(toolCallBlock.type).toBe("toolCall"); + + // 3. Verify toolResult message is redacted + const resultMsg = transformed[2]; + expect(resultMsg.role).toBe("toolResult"); + const toolResultMsg = resultMsg as ToolResultMessage; + const toolResultBlock = toolResultMsg.content[0]; + expect(toolResultBlock.type).toBe("text"); + if (toolResultBlock.type === "text") { + expect(toolResultBlock.text).toBe("Token is [github_token_redacted] inside output"); + } + if (toolCallBlock.type === "toolCall") { + const toolCall = toolCallBlock as ToolCall; + const commandArg = toolCall.arguments?.command; + expect(commandArg).toBe("echo [github_token_redacted]"); + } + }); +}); From 99ecfd6b6c704b1a0a9b6f54e43b0d56693ccd70 Mon Sep 17 00:00:00 2001 From: usr_bin_roygbiv Date: Thu, 16 Jul 2026 00:01:40 -0500 Subject: [PATCH 2/5] fix(ai): avoid JSON.stringify BigInt serialization crash in redactSensitiveInObject --- .../ai/src/providers/transform-messages.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 4b85aa45d..04b89ec45 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -316,21 +316,31 @@ export function redactSensitiveCredentials(text: string): string { }); } -function redactSensitiveInObject(val: unknown): unknown { +function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } { if (typeof val === "string") { - return redactSensitiveCredentials(val); + const redacted = redactSensitiveCredentials(val); + return { result: redacted, changed: redacted !== val }; } if (Array.isArray(val)) { - return val.map(redactSensitiveInObject); + let changed = false; + const result = val.map(item => { + const res = redactSensitiveInObject(item); + if (res.changed) changed = true; + return res.result; + }); + return { result, changed }; } if (val !== null && typeof val === "object") { + let changed = false; const res: Record = {}; for (const [k, v] of Object.entries(val)) { - res[k] = redactSensitiveInObject(v); + const sub = redactSensitiveInObject(v); + if (sub.changed) changed = true; + res[k] = sub.result; } - return res; + return { result: res, changed }; } - return val; + return { result: val, changed: false }; } function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { @@ -391,8 +401,8 @@ function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { } } else if (block.type === "toolCall") { if (block.arguments) { - const redactedArgs = redactSensitiveInObject(block.arguments); - if (JSON.stringify(redactedArgs) !== JSON.stringify(block.arguments)) { + const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments); + if (argsChanged) { changed = true; const castArgs = redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs) From c55196eb3059c00cbdd878fd0d7bc89a5dfd6f44 Mon Sep 17 00:00:00 2001 From: usr_bin_roygbiv Date: Thu, 16 Jul 2026 00:20:24 -0500 Subject: [PATCH 3/5] fix(ai): retry full transcript on blocked stateful OpenAI Responses prompts --- packages/ai/src/providers/openai-responses.ts | 6 ++- .../ai/test/openai-responses-stateful.test.ts | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 2be20dd81..053bc06b3 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -594,7 +594,11 @@ const streamOpenAIResponsesOnce = ( error instanceof Error && /previous[ _]?response/i.test(error.message) && /zero[ _-]?data[ _-]?retention/i.test(error.message); - if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) { + const isPromptBlocked = + error instanceof Error && + ((error as { code?: string }).code === "invalid_prompt" || + /invalid_prompt|Request blocked/i.test(error.message)); + if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) { throw error; } // Server rejected the chain baseline: reset, count the failure (or diff --git a/packages/ai/test/openai-responses-stateful.test.ts b/packages/ai/test/openai-responses-stateful.test.ts index 8f6fb1b1a..8871f0961 100644 --- a/packages/ai/test/openai-responses-stateful.test.ts +++ b/packages/ai/test/openai-responses-stateful.test.ts @@ -230,6 +230,58 @@ describe("openai-responses stateful chaining", () => { expect(JSON.stringify(sentRequests[2]?.input)).toContain("First question"); expect(JSON.stringify(sentRequests[2]?.input)).toContain("Second question"); }); + it("retries a blocked invalid_prompt previous_response_id with the full transcript", async () => { + const sentRequests: Array> = []; + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as Record; + sentRequests.push(request); + if (typeof request.previous_response_id === "string") { + return new Response( + JSON.stringify({ + error: { + message: "Request blocked.", + type: "invalid_request_error", + code: "invalid_prompt", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return createStatefulSse(`Answer ${sentRequests.length}`, `resp_${sentRequests.length}`); + }) as FetchImpl; + const providerSessionState = new Map(); + const options = { + apiKey: "test-key", + sessionId: "stateful-blocked-session", + providerSessionState, + statefulResponses: true, + reasoning: "low" as const, + fetch: fetchMock, + }; + + const firstUser = { role: "user" as const, content: "First question", timestamp: 1000 }; + const firstResponse = await streamOpenAIResponses( + model, + { systemPrompt, messages: [firstUser] }, + options, + ).result(); + const secondResponse = await streamOpenAIResponses( + model, + { + systemPrompt, + messages: [firstUser, firstResponse, { role: "user", content: "Second question", timestamp: 1001 }], + }, + options, + ).result(); + + expect(secondResponse.stopReason).toBe("stop"); + expect(JSON.stringify(secondResponse.content)).toContain("Answer 3"); + expect(sentRequests).toHaveLength(3); + expect(sentRequests[1]?.previous_response_id).toBe("resp_1"); + expect(sentRequests[2]?.previous_response_id).toBeUndefined(); + expect(JSON.stringify(sentRequests[2]?.input)).toContain("First question"); + expect(JSON.stringify(sentRequests[2]?.input)).toContain("Second question"); + }); it("disables chaining for the session after repeated stale failures and stops forcing store", async () => { const sentRequests: Array> = []; From fab31a2baea02f24f0e61785f51a0d6d5060d337 Mon Sep 17 00:00:00 2001 From: usr_bin_roygbiv Date: Thu, 16 Jul 2026 00:24:00 -0500 Subject: [PATCH 4/5] fix(ai): redact sensitive credentials in system prompt instructions --- packages/ai/src/utils.ts | 5 ++++- .../ai/test/openai-responses-system-prompt.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 0445cd3ac..5c8a4bf9d 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -1,5 +1,6 @@ import { $env } from "@oh-my-pi/pi-utils"; import type { ResponseInput, ResponseInputItem } from "./providers/openai-responses-wire"; +import { redactSensitiveCredentials } from "./providers/transform-messages"; import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types"; type OpenAIResponsesReplayItem = ResponseInput[number]; @@ -9,7 +10,9 @@ export { isRecord } from "@oh-my-pi/pi-utils"; export function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[] { if (systemPrompt === undefined || systemPrompt === null) return []; const prompts = Array.isArray(systemPrompt) ? systemPrompt : typeof systemPrompt === "string" ? [systemPrompt] : []; - return prompts.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.trim().length > 0); + return prompts + .map(prompt => redactSensitiveCredentials(prompt.toWellFormed())) + .filter(prompt => prompt.trim().length > 0); } export function normalizeToolCallId(id: string): string { diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index f09888189..26103a45c 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -86,6 +86,16 @@ describe("openai-responses system prompt routing", () => { expect(input.every(m => m.role !== "system")).toBe(true); }); + it("redacts sensitive credentials in instructions", async () => { + const context: Context = { + systemPrompt: ["Token: gho_************************************"], + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }; + const body = await captureRequestBody(gpt4oMiniModel, context); + + expect(body.instructions).toBe("Token: [github_token_redacted]"); + }); + it("omits instructions field when there is no system prompt", async () => { const context: Context = { systemPrompt: undefined, From 6f0d61f7ca170788f2b97c7ab1a7753e6c2544d9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 17 Jul 2026 05:26:18 +0200 Subject: [PATCH 5/5] fix(ai): dropped stale signatures after credential redaction --- .../src/providers/openai-codex-responses.ts | 7 +- .../ai/src/providers/transform-messages.ts | 42 ++++++-- .../test/openai-codex-responses-lite.test.ts | 31 ++++++ ...ransform-messages-redact-sensitive.test.ts | 96 +++++++++++++++++++ 4 files changed, 164 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 510bd110a..e5e00067a 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -112,7 +112,7 @@ import { promoteResponsesToolUseStopReason, type SequentialCutoffSummaryState, } from "./openai-shared"; -import { transformMessages } from "./transform-messages"; +import { redactSensitiveInObject, transformMessages } from "./transform-messages"; export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -3954,13 +3954,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex | Array | undefined; if (historyItems) { - for (const item of historyItems) { + const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array; + for (const item of redactedHistoryItems) { const maybe = item as { type?: string; call_id?: string }; if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") { customCallIds.add(maybe.call_id); } } - messages.push(...historyItems); + messages.push(...redactedHistoryItems); msgIndex += 1; continue; } diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 04b89ec45..ecb6f0d05 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -296,27 +296,47 @@ function normalizeAnthropicTargetToolCallId( * - Injects synthetic "aborted" tool results */ const SENSITIVE_TOKEN_RE = - /\b(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*]{36,}|sk-ant-[a-zA-Z0-9_*]{36,}|sk-[a-zA-Z0-9_*]{48,})(?![a-zA-Z0-9_*])/g; + /(? pattern.test(secret)).length >= 2; +} export function redactSensitiveCredentials(text: string): string { - return text.replace(SENSITIVE_TOKEN_RE, (_match, token) => { - if (token.startsWith("gh")) { + return text.replace(SENSITIVE_TOKEN_RE, match => { + if (!hasPlausibleCredentialEntropy(match)) return match; + const lower = match.toLowerCase(); + if (lower.startsWith("gh")) { return "[github_token_redacted]"; } - if (token.startsWith("gl")) { + if (lower.startsWith("gl")) { return "[gitlab_token_redacted]"; } - if (token.startsWith("sk-ant-")) { + if (lower.startsWith("sk-ant-")) { return "[anthropic_token_redacted]"; } - if (token.startsWith("sk")) { + if (lower.startsWith("sk")) { return "[openai_token_redacted]"; } return "[token_redacted]"; }); } -function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } { +export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } { if (typeof val === "string") { const redacted = redactSensitiveCredentials(val); return { result: redacted, changed: redacted !== val }; @@ -397,7 +417,7 @@ function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { const redacted = redactSensitiveCredentials(block.thinking); if (redacted !== block.thinking) { changed = true; - return { ...block, thinking: redacted }; + return { ...block, thinking: redacted, thinkingSignature: undefined }; } } else if (block.type === "toolCall") { if (block.arguments) { @@ -408,7 +428,11 @@ function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs) ? (redactedArgs as Record) : undefined; - return { ...block, arguments: castArgs } as AssistantMessage["content"][number]; + return { + ...block, + arguments: castArgs, + thoughtSignature: undefined, + } as AssistantMessage["content"][number]; } } } diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index dd3face59..de55b5e16 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -1205,3 +1205,34 @@ describe("openai-codex concurrent reasoning summaries", () => { expect(text?.text).toBe("Hello"); }); }); + +describe("openai-codex native history redaction", () => { + it("redacts credentials from user provider history before replaying it", () => { + const model = createCodexModel("gpt-5.1-codex"); + const credential = "sk-ABCdef1234567890ABCdef1234567890ABCdef1234567890ABCdef123456"; + const context: Context = { + messages: [ + { + role: "user", + content: "fallback", + timestamp: Date.now(), + providerPayload: { + type: "openaiResponsesHistory", + provider: model.provider, + items: [{ type: "message", role: "user", content: [{ type: "input_text", text: credential }] }], + }, + } as Context["messages"][number], + ], + }; + + const messages = convertCodexResponsesMessages(model, context); + + expect(messages).toEqual([ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "[openai_token_redacted]" }], + }, + ]); + }); +}); diff --git a/packages/ai/test/transform-messages-redact-sensitive.test.ts b/packages/ai/test/transform-messages-redact-sensitive.test.ts index 358cd793e..e701f628a 100644 --- a/packages/ai/test/transform-messages-redact-sensitive.test.ts +++ b/packages/ai/test/transform-messages-redact-sensitive.test.ts @@ -102,4 +102,100 @@ describe("transformMessages redact sensitive credentials", () => { expect(commandArg).toBe("echo [github_token_redacted]"); } }); + + it("drops an Anthropic thinking signature when redacting its signed content", () => { + const model = buildModel({ + api: "anthropic-messages", + name: "Claude Test", + id: "claude-test", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + contextWindow: 8192, + maxTokens: 2048, + input: ["text"], + reasoning: true, + compat: { signingEndpoint: true }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Use sk-ABCdef1234567890ABCdef1234567890ABCdef1234567890ABCdef123456.", + thinkingSignature: "signed-thinking-bytes", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + + const transformed = transformMessages(messages, model); + + expect(transformed[0]).toMatchObject({ role: "assistant", content: [] }); + }); + + it("drops a tool thought signature after redacting its arguments", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_signed", + name: "run", + arguments: { token: "sk-ABCdef1234567890ABCdef1234567890ABCdef1234567890ABCdef123456" }, + thoughtSignature: "signed-tool-arguments", + }, + ], + api: "openai-responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + ]; + + const transformed = transformMessages(messages, makeModel()); + const block = (transformed[0] as AssistantMessage).content[0]; + + expect(block).toMatchObject({ + type: "toolCall", + arguments: { token: "[openai_token_redacted]" }, + }); + if (block.type === "toolCall") { + expect(block.thoughtSignature).toBeUndefined(); + } + }); + + it("preserves credential-shaped prose that is not a plausible live token", () => { + const lookalike = "sk-abcdefghijklmnopqrstuvwxyz"; + const transformed = transformMessages( + [{ role: "user", content: `The example key is ${lookalike}.`, timestamp: Date.now() }], + makeModel(), + ); + + expect(transformed[0]).toMatchObject({ role: "user", content: `The example key is ${lookalike}.` }); + }); });