merge PR #5655 via eval/pr-5655: fix(ai): redact sensitive credentials from outbound messages

This commit is contained in:
can1357
2026-07-17 05:29:28 +02:00
8 changed files with 471 additions and 6 deletions
@@ -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<ResponseInput[number]>
| undefined;
if (historyItems) {
for (const item of historyItems) {
const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
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;
}
@@ -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
+164 -1
View File
@@ -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,156 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
* - Preserves tool call structure (unlike converting to text summaries)
* - Injects synthetic "aborted" tool results
*/
const SENSITIVE_TOKEN_RE =
/(?<![a-zA-Z0-9_*-])(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_*-])/gi;
function hasPlausibleCredentialEntropy(token: string): boolean {
const lower = token.toLowerCase();
const prefixLength = lower.startsWith("github_pat_")
? "github_pat_".length
: lower.startsWith("glpat-")
? "glpat-".length
: lower.startsWith("sk-proj-")
? "sk-proj-".length
: lower.startsWith("sk-ant-")
? "sk-ant-".length
: lower.startsWith("gh")
? 4
: 3;
const secret = token.slice(prefixLength);
if (/^\*+$/.test(secret)) return true;
return [/[a-z]/, /[A-Z]/, /\d/, /[_-]/].filter(pattern => pattern.test(secret)).length >= 2;
}
export function redactSensitiveCredentials(text: string): string {
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 (lower.startsWith("gl")) {
return "[gitlab_token_redacted]";
}
if (lower.startsWith("sk-ant-")) {
return "[anthropic_token_redacted]";
}
if (lower.startsWith("sk")) {
return "[openai_token_redacted]";
}
return "[token_redacted]";
});
}
export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } {
if (typeof val === "string") {
const redacted = redactSensitiveCredentials(val);
return { result: redacted, changed: redacted !== val };
}
if (Array.isArray(val)) {
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<string, unknown> = {};
for (const [k, v] of Object.entries(val)) {
const sub = redactSensitiveInObject(v);
if (sub.changed) changed = true;
res[k] = sub.result;
}
return { result: res, changed };
}
return { result: val, changed: false };
}
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, thinkingSignature: undefined };
}
} else if (block.type === "toolCall") {
if (block.arguments) {
const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments);
if (argsChanged) {
changed = true;
const castArgs =
redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs)
? (redactedArgs as Record<string, unknown>)
: undefined;
return {
...block,
arguments: castArgs,
thoughtSignature: undefined,
} as AssistantMessage["content"][number];
}
}
}
return block;
});
return (changed ? { ...assistantMsg, content } : assistantMsg) as Message;
}
return msg;
});
}
export function transformMessages<TApi extends Api>(
messages: Message[],
model: Model<TApi>,
@@ -294,6 +453,10 @@ export function transformMessages<TApi extends Api>(
duplicateToolCallIdSuffixPrefix = "_dup",
targetCompat: Model<TApi>["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
+4 -1
View File
@@ -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 {
@@ -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]" }],
},
]);
});
});
@@ -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<Record<string, unknown>> = [];
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const request = JSON.parse(String(init?.body)) as Record<string, unknown>;
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<string, ProviderSessionState>();
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<Record<string, unknown>> = [];
@@ -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,
@@ -0,0 +1,201 @@
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]");
}
});
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}.` });
});
});