fix(runner): harden browser credential redaction

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-08 07:55:37 +00:00
1 parent 20bb02d325
commit 7fa15aaaf8
2 files changed
+295 -28

No files matched your search

@@ -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<T> implements AsyncIterable<T> {
#values: T[] = [];
#waiters: Array<(value: IteratorResult<T>) => 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<string> {
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("<server-path>");
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",
@@ -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<string, unknown> {
: {};
}
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<string, unknown>).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,
"<server-path>",
);
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<string, unknown> {
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, unknown>): 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);
}
}