diff --git a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts index a09cbed5de3..1065c1fe04d 100644 --- a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts +++ b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts @@ -16,6 +16,16 @@ import type { import type { PrpEvent } from "../protocol/phase1-contract.js"; import { Phase4bDemoServer } from "./phase4b-demo-server.js"; +const OPAQUE_BROWSER_CREDENTIALS = { + clientSecret: "opaque-client-secret-value", + providerApiKey: "opaque-provider-api-key-value", + sessionToken: "opaque-session-token-value", + idToken: "opaque-id-token-value", + password: "opaque-password-value", + authorization: "opaque-authorization-value", + cookie: "opaque-cookie-value", +} as const; + class Queue implements AsyncIterable { #values: T[] = []; #waiters: Array<(value: IteratorResult) => void> = []; @@ -127,7 +137,24 @@ class StubSession implements HarnessSession { itemId: "command-demo", status: "pending", prompt: "Approve the deterministic command.", - details: { authorization: "Bearer browser-must-not-see" }, + details: { + provider: { + ...OPAQUE_BROWSER_CREDENTIALS, + nested: { + signingKey: "opaque-signing-key-value", + private_key: "opaque-private-key-value", + }, + }, + usage: { + tokenBudget: 4096, + tokensUsed: 128, + tokenUsage: { + inputTokens: 96, + outputTokens: 32, + }, + }, + invalidUsage: { tokenBudget: "opaque-token-budget-string" }, + }, }; this.pending = [request]; this.emit("runtime_request.created", { @@ -370,6 +397,37 @@ async function openEventStream( }); } +async function readEventStreamReplay(url: string, sessionId: string): Promise { + const response = await browserRequest( + url, + `/api/phase4b/sessions/${sessionId}/stream?after=0`, + ); + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + if (reader === undefined) throw new Error("event stream response did not include a body"); + let result = ""; + try { + while (!result.includes("data:")) { + const chunk = await reader.read(); + if (chunk.done) break; + result += new TextDecoder().decode(chunk.value, { stream: true }); + } + } finally { + await reader.cancel(); + } + return result; +} + +function expectOpaqueCredentialsRedacted(serialized: string): void { + for (const value of Object.values(OPAQUE_BROWSER_CREDENTIALS)) { + expect(serialized).not.toContain(value); + } + expect(serialized).not.toContain("opaque-signing-key-value"); + expect(serialized).not.toContain("opaque-private-key-value"); + expect(serialized).not.toContain("opaque-token-budget-string"); + expect(serialized).toContain("[REDACTED]"); +} + afterEach(async () => { await Promise.all(servers.splice(0).map((server) => server.close())); }); @@ -488,7 +546,7 @@ describe("Phase 4b package-local demo server", () => { const credentialAttempt = await jsonBrowserRequest(url, "/api/phase4b/sessions", { objective: "Browser must not choose provider credentials.", - openaiApiKey: "browser-secret", + clientSecret: "browser-secret", }); expect(credentialAttempt.status).toBe(422); await expect(credentialAttempt.json()).resolves.toMatchObject({ @@ -577,6 +635,29 @@ describe("Phase 4b package-local demo server", () => { expect(JSON.stringify(created)).not.toContain("browser-must-not-see"); expect(JSON.stringify(created)).not.toContain("server-only-secret"); expect(JSON.stringify(created)).not.toContain("/srv/paperclip/home"); + expectOpaqueCredentialsRedacted(JSON.stringify(created)); + expect(created).toMatchObject({ + pendingRequests: [{ + details: { + provider: { + clientSecret: "[REDACTED]", + providerApiKey: "[REDACTED]", + sessionToken: "[REDACTED]", + idToken: "[REDACTED]", + nested: { + signingKey: "[REDACTED]", + private_key: "[REDACTED]", + }, + }, + usage: { + tokenBudget: 4096, + tokensUsed: 128, + tokenUsage: { inputTokens: 96, outputTokens: 32 }, + }, + invalidUsage: { tokenBudget: "[REDACTED]" }, + }, + }], + }); const sessionId = String(created.sessionId); const replay = await browserRequest(url, `/api/phase4b/sessions/${sessionId}/events?after=0`) @@ -586,6 +667,12 @@ describe("Phase 4b package-local demo server", () => { expect(replay.events.every((event, index) => event.sourceSeq === index + 1)).toBe(true); expect(JSON.stringify(replay)).not.toContain("/srv/paperclip/home"); expect(JSON.stringify(replay)).toContain(""); + expectOpaqueCredentialsRedacted(JSON.stringify(replay)); + + const streamReplay = await readEventStreamReplay(url, sessionId); + expectOpaqueCredentialsRedacted(streamReplay); + expect(streamReplay).toContain('"tokenBudget":4096'); + expect(streamReplay).toContain('"tokensUsed":128'); const otherSession = await jsonBrowserRequest(url, "/api/phase4b/sessions", { objective: "Keep request scope separate.", @@ -658,6 +745,33 @@ describe("Phase 4b package-local demo server", () => { expect(afterReconnect.events.some(({ eventType }) => eventType === "session.resumed")).toBe(true); }); + it("redacts structured credential diagnostics and bounds browser-visible errors", async () => { + const errorSecret = "opaque-error-client-secret"; + const server = new Phase4bDemoServer({ + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => { + throw new Error( + `provider failed: {"clientSecret":"${errorSecret}"} ${"x".repeat(100_000)}`, + ); + }, + }); + servers.push(server); + const { url } = await server.start(); + + const response = await browserRequest(url, "/api/phase4b/sessions", { + method: "POST", + headers: { "connection": "close", "content-type": "application/json" }, + body: JSON.stringify({ objective: "Exercise a structured provider error." }), + }); + expect(response.status).toBe(400); + const body = await response.json() as { error: string; message: string }; + expect(body.error).toBe("invalid_request"); + expect(body.message).not.toContain(errorSecret); + expect(body.message).toContain('"clientSecret":"[REDACTED]"'); + expect(body.message.length).toBeLessThanOrEqual(16 * 1024 + "[TRUNCATED]".length); + expect(body.message).toMatch(/\[TRUNCATED\]$/); + }); + it("admits an origin-less same-origin stream but still requires an origin to mutate", async () => { const server = new Phase4bDemoServer({ workingDirectory: "/safe/server-owned-workspace", diff --git a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts index c098a288bd1..ce803d29919 100644 --- a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts +++ b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts @@ -31,6 +31,10 @@ import { redactCodexDiagnostic } from "../drivers/codex/app-server-transport.js" const MAX_BROWSER_BODY_BYTES = 64 * 1024; const MAX_BROWSER_EVENTS = 4096; +const MAX_BROWSER_STRING_CHARACTERS = 16 * 1024; +const MAX_BROWSER_TOTAL_STRING_CHARACTERS = 512 * 1024; +const MAX_BROWSER_VALUE_NODES = 65_536; +const MAX_BROWSER_SERIALIZED_BYTES = 4 * 1024 * 1024; const DEFAULT_MAX_ACTIVE_SESSIONS = 16; const DEFAULT_MAX_SESSION_SUBSCRIBERS = 4; const MAX_CONFIGURED_ACTIVE_SESSIONS = 64; @@ -93,17 +97,61 @@ function record(value: unknown): Record { : {}; } -function browserSafe(value: unknown, depth = 0): unknown { +interface BrowserSafeBudget { + nodesRemaining: number; + stringCharactersRemaining: number; +} + +const BROWSER_SAFE_CREDENTIAL_METADATA_KEYS = new Set([ + "credentialsexposed", + "providerauthentication", +]); + +const BROWSER_SAFE_USAGE_KEYS = new Set([ + "cachedinputtokens", + "inputtokens", + "outputtokens", + "reasoningoutputtokens", + "tokenbudget", + "tokensused", + "tokenusage", + "totaltokens", +]); + +function browserSafe(value: unknown): unknown { + return browserSafeValue(value, 0, { + nodesRemaining: MAX_BROWSER_VALUE_NODES, + stringCharactersRemaining: MAX_BROWSER_TOTAL_STRING_CHARACTERS, + }); +} + +function browserSafeValue(value: unknown, depth: number, budget: BrowserSafeBudget): unknown { if (depth > 10) return "[TRUNCATED]"; - if (typeof value === "string") return redactBrowserString(value); - if (Array.isArray(value)) return value.slice(0, 256).map((entry) => browserSafe(entry, depth + 1)); + if (budget.nodesRemaining <= 0) return "[TRUNCATED]"; + budget.nodesRemaining -= 1; + if (typeof value === "string") { + const redacted = redactBrowserString(value); + const allowedCharacters = Math.min( + redacted.length, + budget.stringCharactersRemaining, + ); + budget.stringCharactersRemaining -= allowedCharacters; + return allowedCharacters === redacted.length + ? redacted + : `${redacted.slice(0, allowedCharacters)}[TRUNCATED]`; + } + if (Array.isArray(value)) { + return value + .slice(0, 256) + .map((entry) => browserSafeValue(entry, depth + 1, budget)); + } if (typeof value !== "object" || value === null) return value; return Object.fromEntries( Object.entries(value as Record).slice(0, 256).map(([key, entry]) => [ key, - isSensitiveBrowserKey(key) + isSensitiveBrowserEntry(key, entry) ? "[REDACTED]" - : browserSafe(entry, depth + 1), + : browserSafeValue(entry, depth + 1, budget), ]), ); } @@ -114,20 +162,133 @@ function browserSafe(value: unknown, depth = 0): unknown { * `.paperclip` — and none of them mean anything to a browser. */ function redactBrowserString(value: string): string { - return redactCodexDiagnostic(value).replace( + const truncated = value.length > MAX_BROWSER_STRING_CHARACTERS; + const visible = truncated ? value.slice(0, MAX_BROWSER_STRING_CHARACTERS) : value; + const redacted = redactStructuredBrowserCredentials(redactCodexDiagnostic(visible)).replace( /\/(?:srv\/paperclip\/home|home\/[^/\s]+|Users\/[^/\s]+)\/\.[^\s"'<>),\]}]*/g, "", ); + return truncated ? `${redacted}[TRUNCATED]` : redacted; } function isSensitiveBrowserKey(key: string): boolean { - return /^(?:api[_-]?key|token|accessToken|refreshToken|secret|password|authorization|cookie)$/i.test(key) || - /(?:^|_)(?:api[_-]?key|token|secret|password|authorization|cookie)$/i.test(key); + const normalized = normalizeBrowserKey(key); + if ( + BROWSER_SAFE_CREDENTIAL_METADATA_KEYS.has(normalized) || + BROWSER_SAFE_USAGE_KEYS.has(normalized) + ) { + return false; + } + const words = key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + if (words.some((word) => + [ + "authorization", + "cookie", + "cookies", + "credential", + "credentials", + "passwd", + "passwds", + "password", + "passwords", + "secret", + "secrets", + "token", + "tokens", + ].includes(word) + )) { + return true; + } + if (words.some((word, index) => + ["access", "api", "private", "signing"].includes(word) && + ["key", "keys"].includes(words[index + 1] ?? "") + )) { + return true; + } + return /(?:apikeys?|accesskeys?|privatekeys?|signingkeys?|credentials?|passwds?|passwords?|secrets?|tokens?)$/.test( + normalized, + ); +} + +function isSensitiveBrowserEntry(key: string, value: unknown): boolean { + const normalized = normalizeBrowserKey(key); + if (normalized === "credentialsexposed") return typeof value !== "boolean"; + if (normalized === "providerauthentication") return value !== "server-side"; + if (BROWSER_SAFE_USAGE_KEYS.has(normalized)) { + if (normalized === "tokenusage") return !isBrowserRecord(value); + if (normalized === "tokenbudget" && value === null) return false; + return typeof value !== "number" || !Number.isFinite(value); + } + return isSensitiveBrowserKey(key); +} + +function isBrowserRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeBrowserKey(key: string): string { + return key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); +} + +function redactStructuredBrowserCredentials(value: string): string { + return value + .replace( + /(["'])([A-Za-z][A-Za-z0-9_-]{0,127})\1(\s*:\s*)(["'])([^"'\r\n]*)/g, + ( + match, + keyQuote: string, + key: string, + separator: string, + valueQuote: string, + fieldValue: string, + ) => + isSensitiveBrowserDiagnosticField(key, fieldValue) + ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}[REDACTED]` + : match, + ) + .replace( + /\b([A-Za-z][A-Za-z0-9_-]{0,127})(\s*[=:]\s*)([^\s,;}\]]+)/g, + (match, key: string, separator: string, fieldValue: string) => + isSensitiveBrowserDiagnosticField(key, fieldValue) + ? `${key}${separator}[REDACTED]` + : match, + ); +} + +function isSensitiveBrowserDiagnosticField(key: string, value: string): boolean { + const normalized = normalizeBrowserKey(key); + if (normalized === "credentialsexposed") return !/^(?:true|false)$/i.test(value); + if (normalized === "providerauthentication") return value !== "server-side"; + if (BROWSER_SAFE_USAGE_KEYS.has(normalized)) { + if (normalized === "tokenusage") return true; + if (normalized === "tokenbudget" && value === "null") return false; + return !/^-?\d+(?:\.\d+)?$/.test(value); + } + return isSensitiveBrowserKey(key); +} + +function serializeBrowserJson(body: unknown): { serialized: string; overflow: boolean } { + const serialized = JSON.stringify(browserSafe(body)) ?? "null"; + if (Buffer.byteLength(serialized) <= MAX_BROWSER_SERIALIZED_BYTES) { + return { serialized, overflow: false }; + } + return { + serialized: JSON.stringify({ + error: "browser_response_too_large", + message: `Browser response exceeded ${MAX_BROWSER_SERIALIZED_BYTES} bytes`, + }), + overflow: true, + }; } function json(response: ServerResponse, status: number, body: unknown): void { - const serialized = JSON.stringify(browserSafe(body)); - response.writeHead(status, { + const { serialized, overflow } = serializeBrowserJson(body); + response.writeHead(overflow ? 413 : status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "content-length": Buffer.byteLength(serialized), @@ -322,18 +483,7 @@ function boundedOption( } function browserCredentialField(body: Record): string | null { - const forbidden = new Set([ - "authorization", - "cookie", - "credentials", - "apikey", - "paperclipapikey", - "openaiapikey", - "providertoken", - "refreshtoken", - "accesstoken", - ]); - return Object.keys(body).find((key) => forbidden.has(key.replace(/[-_]/g, "").toLowerCase())) ?? null; + return Object.keys(body).find((key) => isSensitiveBrowserKey(key)) ?? null; } export class Phase4bDemoServer { @@ -402,7 +552,7 @@ export class Phase4bDemoServer { } catch (error) { json(response, errorStatus(error), { error: errorCode(error), - message: redactCodexDiagnostic(String(error instanceof Error ? error.message : error)), + message: String(error instanceof Error ? error.message : error), }); return; } @@ -410,7 +560,7 @@ export class Phase4bDemoServer { if (!response.headersSent) { json(response, errorStatus(error), { error: errorCode(error), - message: redactCodexDiagnostic(String(error)), + message: String(error), }); } else { response.end(); @@ -529,7 +679,8 @@ export class Phase4bDemoServer { // bytes sent, leaving the browser stuck in CONNECTING. response.write(": stream open\n\n"); for (const event of entry.events.slice(after)) { - response.write(`data: ${JSON.stringify(browserSafe(event))}\n\n`); + const output = serializeBrowserJson(event); + if (!output.overflow) response.write(`data: ${output.serialized}\n\n`); } entry.subscribers.add(response); request.once("close", () => entry.subscribers.delete(response)); @@ -667,9 +818,11 @@ export class Phase4bDemoServer { const event = browserSafe(rawEvent) as PrpEvent; const validation = validatePrpEvent(event); if (!validation.ok) continue; + const output = serializeBrowserJson(event); + if (output.overflow) continue; if (entry.events.length >= MAX_BROWSER_EVENTS) entry.events.shift(); entry.events.push(event); - const serialized = `data: ${JSON.stringify(event)}\n\n`; + const serialized = `data: ${output.serialized}\n\n`; for (const subscriber of entry.subscribers) subscriber.write(serialized); } }