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
10 changes: 9 additions & 1 deletion src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1261,8 +1262,15 @@ async function readResponseBodyPreview(response: Response): Promise<string> {
}

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]"`,
);
}
Expand Down
10 changes: 5 additions & 5 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 1 addition & 4 deletions src/connectors/mcp-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ConnectorIngestResult,
McpConnectorConfig,
} from "./types.js";
import { isSecretLikeKey } from "../diagnostics.js";

export type McpConnectorId = Extract<ConnectorId, "notion">;

Expand Down Expand Up @@ -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);
}
21 changes: 21 additions & 0 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions test/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading