fix(ai): redact sensitive credentials from outbound messages

This commit is contained in:
usr_bin_roygbiv
2026-07-15 23:56:19 -05:00
committed by can1357
parent 19f40f0b5d
commit b59ee9a99c
2 changed files with 235 additions and 1 deletions
+130 -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,122 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
* - 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<string, unknown> = {};
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<string, unknown>)
: undefined;
return { ...block, arguments: castArgs } 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 +419,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
@@ -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]");
}
});
});