diff --git a/src/agent/index.ts b/src/agent/index.ts index 6b151cae..0f9455c4 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -16,6 +16,7 @@ import { saveOpenWikiEnv, } from "../env.js"; import { isFileNotFoundError } from "../fs-errors.js"; +import { SECRET_KEY_PATTERN_SOURCE } from "../diagnostics.js"; import { openWikiLocalWikiDir } from "../openwiki-home.js"; import { OpenWikiLocalShellBackend } from "./docs-only-backend.js"; import { @@ -1261,8 +1262,15 @@ async function readResponseBodyPreview(response: Response): Promise { } export function sanitizeOpenRouterResponseBody(body: string): string { + // Redact string values whose JSON key name contains any secret-bearing term + // (shared source of truth with isSecretLikeKey / the MCP redactor). + const secretJsonKeyPattern = new RegExp( + `"([^"]*(?:${SECRET_KEY_PATTERN_SOURCE})[^"]*)"\\s*:\\s*"[^"]*"`, + "giu", + ); + return body.replace( - /"([^"]*(?:api[-_]?key|authorization|bearer|password|secret|token|user_id)[^"]*)"\s*:\s*"[^"]*"/giu, + secretJsonKeyPattern, (_, key: string) => `${JSON.stringify(key)}:"[REDACTED]"`, ); } diff --git a/src/cli.tsx b/src/cli.tsx index f264269a..a4c79124 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -33,7 +33,11 @@ import { } from "./env.js"; import { createOpenWikiThreadId, runOpenWikiAgent } from "./agent/index.js"; import { formatChatGptAccountFromEnv } from "./agent/openai-chatgpt-oauth.js"; -import { getErrorMessage, sanitizeDiagnosticText } from "./diagnostics.js"; +import { + getErrorMessage, + isSecretLikeKey, + sanitizeDiagnosticText, +} from "./diagnostics.js"; import { stripHtmlTags } from "./utils.js"; import { type OpenWikiRunEvent, @@ -3269,10 +3273,6 @@ function createDiagnosticJsonReplacer() { }; } -function isSecretLikeKey(key: string): boolean { - return /api[-_]?key|authorization|bearer|token|secret|password/iu.test(key); -} - function truncateDiagnosticValue(value: string): string { const maxLength = 2_000; const normalizedValue = value.trim(); diff --git a/src/connectors/mcp-runtime.ts b/src/connectors/mcp-runtime.ts index 14463648..1140baaa 100644 --- a/src/connectors/mcp-runtime.ts +++ b/src/connectors/mcp-runtime.ts @@ -16,6 +16,7 @@ import type { ConnectorIngestResult, McpConnectorConfig, } from "./types.js"; +import { isSecretLikeKey } from "../diagnostics.js"; export type McpConnectorId = Extract; @@ -272,7 +273,3 @@ function sanitizeValue(value: unknown): unknown { return value; } - -function isSecretLikeKey(key: string): boolean { - return /(token|secret|password|authorization|api[-_]?key|cookie)/iu.test(key); -} diff --git a/src/diagnostics.ts b/src/diagnostics.ts index ca840756..c6fefa8a 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -48,6 +48,27 @@ export function sanitizeDiagnosticText(value: string): string { .replace(/\bls[v_][A-Za-z0-9_-]+/gu, "[REDACTED:LANGSMITH_API_KEY]"); } +/** + * True when an object key name looks like it holds a credential, so its value + * should be redacted before display, logging, or persistence. + * + * This is a security boundary shared by every redaction path (diagnostics, + * OpenRouter response bodies, and MCP tool args/results). The term list is the + * union of every term the individual paths previously matched, so a key + * redacted by one path is redacted by all of them. + */ +/** + * The union of every substring that marks a key/field name as secret-bearing. + * Single source of truth for all redaction paths — extend this, not the + * individual call sites. + */ +export const SECRET_KEY_PATTERN_SOURCE = + "api[-_]?key|authorization|bearer|token|secret|password|user_id|cookie"; + +export function isSecretLikeKey(key: string): boolean { + return new RegExp(SECRET_KEY_PATTERN_SOURCE, "iu").test(key); +} + /** * Recognizes an OpenRouter/provider 500 response so a friendlier, actionable * message can be shown instead of a raw stack trace. diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 1908fa32..7ad32b1f 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -2,10 +2,59 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { getErrorMessage, isOpenRouterServerError, + isSecretLikeKey, sanitizeDiagnosticText, } from "../src/diagnostics.ts"; import { sanitizeOpenRouterResponseBody } from "../src/agent/index.ts"; +describe("isSecretLikeKey", () => { + // The shared predicate must be the union of every term the three former + // implementations (cli.tsx, agent/index.ts, mcp-runtime.ts) matched, so a key + // redacted by one path is redacted by all of them. + test.each([ + "apiKey", + "api_key", + "api-key", + "authorization", + "Bearer", + "access_token", + "refresh_token", + "client_secret", + "password", + "user_id", + "Cookie", + ])("flags secret-bearing key %s (case-insensitive)", (key) => { + expect(isSecretLikeKey(key)).toBe(true); + expect(isSecretLikeKey(key.toUpperCase())).toBe(true); + }); + + test.each(["email", "plan", "model", "count", "name", "url"])( + "does not flag benign key %s", + (key) => { + expect(isSecretLikeKey(key)).toBe(false); + }, + ); +}); + +describe("sanitizeOpenRouterResponseBody", () => { + test("redacts values for the unified secret key set", () => { + const body = JSON.stringify({ + access_token: "should-be-hidden", + user_id: "u-123", + cookie: "session=abc", + model: "gpt-5.5", + }); + const sanitized = sanitizeOpenRouterResponseBody(body); + + expect(sanitized).not.toContain("should-be-hidden"); + expect(sanitized).not.toContain("u-123"); + expect(sanitized).not.toContain("session=abc"); + expect(sanitized).toContain("[REDACTED]"); + // Non-secret fields are preserved. + expect(sanitized).toContain("gpt-5.5"); + }); +}); + describe("sanitizeDiagnosticText", () => { const originalOpenAiKey = process.env.OPENAI_API_KEY; const originalOpenAiCompatibleKey = process.env.OPENAI_COMPATIBLE_API_KEY;