diff --git a/doc/observability.md b/doc/observability.md index 0014bacddb1..b4bbd0d60c8 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -1548,9 +1548,14 @@ without one, the report has no synthetic reporting stack. Credential patterns, the current user's home path, registered run-secret values, and known host/runtime environment credentials are removed before truncation. -Declared environment secret bindings are included even when their key has no -credential-like name. Runtime values are used only for redaction and are never -copied into an event. Stack fields are limited to 8,192 characters, +Environment values stay private unless their names and values identify an ordinary +process setting. Flags and counts require both a setting-name match and a boolean +or numeric value. Mode labels use a closed value list. Paths, regions, and key +selectors such as `SORT_KEY` use explicit ordinary-setting names. Unknown opaque +values remain private, including credentials under arbitrary names. Declared +secret bindings and recognizable credential contents override ordinary-setting +exemptions, including short values. Runtime values are used only for redaction +and are never copied into an event. Stack fields are limited to 8,192 characters, exception messages to 2,048, provider titles to 4,096, and provider details to 12,288. Other diagnostic strings are limited to 200 characters (adapter cause messages: 2,048). Truncation is marked in the text and in diff --git a/packages/adapter-utils/src/acpx-engine/terminal-session-failure.test.ts b/packages/adapter-utils/src/acpx-engine/terminal-session-failure.test.ts index e1fdad45b7d..15c9885a876 100644 --- a/packages/adapter-utils/src/acpx-engine/terminal-session-failure.test.ts +++ b/packages/adapter-utils/src/acpx-engine/terminal-session-failure.test.ts @@ -87,7 +87,7 @@ describe("terminal session failure diagnostics", () => { ); }); - it("redacts unknown inherited launch values while preserving public process context", () => { + it("keeps opaque inherited values private while preserving public process settings", () => { const diagnostic = sanitizeTerminalSessionFailure({ category: "service", details: "request_id=req_123 inherited-canary A /workspace", @@ -95,6 +95,19 @@ describe("terminal session failure diagnostics", () => { expect(diagnostic.details).toBe("request_id=req_123 ***REDACTED*** ***REDACTED*** /workspace"); }); + it("masks an inherited Bedrock token without a credential label", () => { + const token = "opaque-bedrock-credential"; + const diagnostic = sanitizeTerminalSessionFailure({ category: "access", details: `upstream rejected ${token}` }, { AWS_BEARER_TOKEN_BEDROCK: token }); + expect(diagnostic.details).toBe("upstream rejected ***REDACTED***"); + }); + + it("preserves ordinary key selectors and short launch flags", () => { + const details = "column created_at does not exist; model gpt-6.1-sol; safely retry"; + expect(sanitizeTerminalSessionFailure({ category: "service", details }, { + SORT_KEY: "created_at", FEATURE_FLAG: "1", WORKER_COUNT: "2", MODE: "safe", + }).details).toBe(details); + }); + it("preserves known boolean settings, HTTP codes and request IDs", () => { const details = "HTTP 401 request_id=req_123 req-1 /1/path retries=1"; const diagnostic = sanitizeTerminalSessionFailure({ category: "access", details }, { diff --git a/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts b/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts index c83e23e31ca..8d7c2758adb 100644 --- a/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts +++ b/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts @@ -1,4 +1,4 @@ -import { redactDiagnosticText, REDACTED_COMMAND_TEXT_VALUE } from "../command-redaction.js"; +import { collectDiagnosticEnvironmentSecrets, redactDiagnosticText, REDACTED_COMMAND_TEXT_VALUE } from "../command-redaction.js"; import type { AdapterExecutionResult } from "../types.js"; export interface AcpxTerminalSessionFailure { @@ -32,16 +32,6 @@ export function classifyToolDefinitionFailure( // Leave room under the server's 64 KiB run-log chunk limit even when every // retained character needs JSON escaping. The transcript stores the text once. const FIELD_LIMITS = { title: 4096, details: 24576 } as const; -// Only conventional public process settings may survive by default. Provider, -// proxy, bridge, and future launch contributions may carry secrets under any -// name. Explicitly configured values are still redacted even for these keys. -const PUBLIC_ENV_KEYS = new Set([ - "PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE", - "HOMEDRIVE", "HOMEPATH", "USER", "USERNAME", "LOGNAME", "SHELL", "LANG", - "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "NODE_ENV", - "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", - "PAPERCLIP_AGENT_ID", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_TASK_ID", -]); const PUBLIC_BOOLEAN_ENV_KEYS = new Set([ "OPENCODE_ALLOW_ALL_MODELS", "CLAUDE_CODE_USE_BEDROCK", "GOOGLE_GENAI_USE_GCA", "CI", "NO_COLOR", "FORCE_COLOR", @@ -58,23 +48,12 @@ export function sanitizeTerminalSessionFailure( authToken?: string, configuredEnv: Record = {}, ): AcpxTerminalSessionFailureDiagnostic { - const secrets = Object.entries(env) - .filter(([key, value]) => value && !PUBLIC_ENV_KEYS.has(key.toUpperCase()) && !isPublicBooleanSetting(key, value)) - .map(([, value]) => value); + const secrets = collectDiagnosticEnvironmentSecrets(env); // Configured values can be resolved secret_refs under arbitrary names (for // example DATABASE_URL). Key-name heuristics cannot establish they are public. for (const [key, value] of Object.entries(configuredEnv)) { if (typeof value === "string" && value && !isPublicBooleanSetting(key, value)) secrets.push(value); } - // A provider may echo just the password from a configured connection URL. - for (const value of Object.values(env)) { - try { - const url = new URL(value); - if (url.password) { - secrets.push(value, url.password, decodeURIComponent(url.password)); - } - } catch { /* ordinary environment values are not URLs */ } - } if (authToken) secrets.push(authToken); const secretForms = [...new Set(secrets.flatMap((value) => { const forms = [value, JSON.stringify(value).slice(1, -1)]; diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 2afd383a6d9..73ed4fed09d 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -11,6 +11,7 @@ describe("explicit diagnostic credential forms", () => { }); import { REDACTED_COMMAND_TEXT_VALUE, + collectDiagnosticEnvironmentSecrets, looksLikeCredentialJwt, redactDiagnosticText, redactCommandText, @@ -184,3 +185,46 @@ describe("redactCommandText on long runs", () => { } }); }); + + +describe("diagnostic environment secret selection", () => { + it("does not infer secrets from ordinary configuration values", () => { + expect(collectDiagnosticEnvironmentSecrets({ + FEATURE_FLAG: "1", WORKER_COUNT: "2", MODE: "safe", REGION: "us-east-1", + NODE_ENV: "production", HOME: "/home/operator", PORT: "3100", MAX_TOKENS: "4096", + PUBLIC_KEY_PATH: "/workspace/key.pem", KEYBOARD_LAYOUT: "us", + SORT_KEY: "created_at", CACHE_KEY: "cache-v1", ROUTING_KEY: "jobs", + PARTITION_KEY: "tenant_id", IDEMPOTENCY_KEY: "operation-123", PUBLIC_KEY: "public-material", + })).toEqual([]); + }); + + it.each(["API_KEY", "KEY", "ACCESS_TOKEN", "AWS_SECRET_ACCESS_KEY", "AWS_ACCESS_KEY_ID", + "CLIENT_SECRET", "PASSWORD", "AUTHORIZATION", "PRIVATE_KEY", "API_KEY_PRODUCTION", "AWS_BEARER_TOKEN_BEDROCK"])( + "keeps short credentials private under %s", key => { + expect(collectDiagnosticEnvironmentSecrets({ [key]: "1" })).toEqual(["1"]); + }, + ); + + it("recognizes credential content and declared bindings under arbitrary names", () => { + const jwt = `${Buffer.from('{"alg":"HS256"}').toString("base64url")}.e30.c2ln`; + const pem = "-----BEGIN PRIVATE KEY-----\nprivate-canary\n-----END PRIVATE KEY-----"; + const key = "sk-fixturecredential12345"; + const selected = collectDiagnosticEnvironmentSecrets({ A: jwt, B: pem, C: key, D: "safe", FEATURE_FLAG: "false" }, ["D"]); + expect(new Set(selected)).toEqual(new Set([jwt, pem, key, "safe"])); + }); + + it("keeps unknown opaque values private without declarations", () => { + expect(collectDiagnosticEnvironmentSecrets({ CUSTOM_BINDING: "opaque-host-secret" })).toEqual(["opaque-host-secret"]); + }); + + it("does not exempt credentials on ordinary-setting names", () => { + expect(collectDiagnosticEnvironmentSecrets({ SORT_KEY: "opaque-secret", FEATURE_FLAG: "1" }, ["SORT_KEY", "FEATURE_FLAG"])) + .toEqual(["opaque-secret", "1"]); + }); + + it("extracts encoded URL passwords and deduplicates selected values", () => { + const url = "postgres://user:private%20password@db.test/database"; + expect(new Set(collectDiagnosticEnvironmentSecrets({ DATABASE_URL: url, PASSWORD: "private password" }))) + .toEqual(new Set([url, "private%20password", "private password"])); + }); +}); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 451f0ccf58e..754ed834e31 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -147,3 +147,76 @@ export function redactDiagnosticText( .replace(JSON_ESCAPED_SECRET_FIELD_RE, `$1${redactedValue}$2`) .replace(JSON_SECRET_FIELD_RE, `$1${redactedValue}$2`); } + +const PUBLIC_ENV_KEYS = new Set([ + "PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE", + "HOMEDRIVE", "HOMEPATH", "USER", "USERNAME", "LOGNAME", "SHELL", "LANG", + "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "NODE_ENV", + "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", + "PWD", "OLDPWD", "INIT_CWD", "SHLVL", "_", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM", + "NPM_LIFECYCLE_EVENT", "NPM_COMMAND", "NPM_EXEC_PATH", "NPM_EXECPATH", "NPM_NODE_EXECPATH", + "NPM_PACKAGE_NAME", "NPM_PACKAGE_VERSION", "NPM_PACKAGE_JSON", "NPM_CONFIG_USER_AGENT", + "XPC_SERVICE_NAME", "VITEST_POOL_ID", "VITEST_WORKER_ID", + "REGION", "AWS_REGION", "AWS_DEFAULT_REGION", "KEYBOARD_LAYOUT", "PUBLIC_KEY_PATH", "PUBLIC_KEY", + "SORT_KEY", "CACHE_KEY", "ROUTING_KEY", "PARTITION_KEY", "IDEMPOTENCY_KEY", + "PAPERCLIP_AGENT_ID", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_TASK_ID", +]); +const PUBLIC_BOOLEAN_ENV_KEYS = new Set([ + "OPENCODE_ALLOW_ALL_MODELS", "CLAUDE_CODE_USE_BEDROCK", "GOOGLE_GENAI_USE_GCA", + "CI", "NO_COLOR", "FORCE_COLOR", + "VITEST", "MALLOCNANOZONE", "CODEX_CI", "CODEX_SHELL", "CODEX_SAGE_BACKFILL_TRACKER_TAB_REUSE", + "DEV", "PROD", "SSR", "PAPERCLIP_REQUIRE_SENTRY_TEST_SDK", + "PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_WORKSPACE_COMPACTION_ENABLED", "PAPERCLIP_AUTH_DISABLE_SIGN_UP", "PAPERCLIP_ENABLE_COMPANY_DELETION", + "PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "PAPERCLIP_SECRETS_STRICT_MODE", + "SERVE_UI", "PAPERCLIP_UI_DEV_MIDDLEWARE", "HEARTBEAT_SCHEDULER_ENABLED", "PAPERCLIP_ANNOUNCEMENTS_ENABLED", + "GITHUB_ACTIONS", "GITHUB_REF_PROTECTED", "RUNNER_DEBUG", "ACTIONS_RUNNER_DEBUG", "ACTIONS_STEP_DEBUG", +]); +const PUBLIC_NUMBER_ENV_KEYS = new Set([ + "PORT", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", "PAPERCLIP_DB_BACKUP_RETENTION_DAYS", + "PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "PAPERCLIP_WORKSPACE_COMPACTION_DELAY_HOURS", + "HEARTBEAT_SCHEDULER_INTERVAL_MS", + "GITHUB_RUN_ATTEMPT", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ID", "GITHUB_RETENTION_DAYS", +]); + +// A setting name and a bounded value must agree before it is public. Unknown +// opaque values remain private; declared bindings always override these rules. +function isOrdinaryDiagnosticEnvSetting(key: string, value: string): boolean { + const name = key.toUpperCase(); + if (PUBLIC_ENV_KEYS.has(name)) return true; + if ((PUBLIC_BOOLEAN_ENV_KEYS.has(name) || /(?:^|_)(?:FLAG|ENABLED|DISABLED|DEBUG)$/.test(name)) && + /^(?:0|1|true|false)$/i.test(value)) return true; + if ((PUBLIC_NUMBER_ENV_KEYS.has(name) || /(?:^|_)(?:COUNT|WORKERS|PORT|MAX_TOKENS)$/.test(name)) && + /^\d+$/.test(value)) return true; + return (name === "BASE_URL" && value === "/") || + (/(?:^|_)MODE$/.test(name) && /^(?:test|development|production|safe)$/.test(value)) || + (name === "PAPERCLIP_SENTRY_RUN_FAILURES" && /^(?:off|unexpected|all)$/.test(value)); +} + +/** Select private values while preserving explicit ordinary process settings. */ +export function collectDiagnosticEnvironmentSecrets( + env: unknown, + secretKeys: Iterable = [], +): string[] { + if (!env || typeof env !== "object") return []; + const declared = new Set(secretKeys); + const secretName = new RegExp(`^(?:${SECRET_NAME_PATTERN})$`, "i"); + const values = new Set(); + for (const [key, value] of Object.entries(env)) { + if (typeof value !== "string" || !value) continue; + if (declared.has(key) || secretName.test(key) || + redactDiagnosticText(value) !== value || looksLikeCredentialJwt(value) || + /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/.test(value) || + !isOrdinaryDiagnosticEnvSetting(key, value)) { + values.add(value); + } + try { + const url = new URL(value); + if (url.password) { + values.add(value); + values.add(url.password); + values.add(decodeURIComponent(url.password)); + } + } catch { /* ordinary settings and malformed URLs need no URL projection */ } + } + return [...values].sort((a, b) => b.length - a.length); +} diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index a11b3f6dfd2..4a7d20b9e99 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -551,9 +551,27 @@ describe("run failure diagnostics", () => { it("selects declared environment secrets under opaque names and common credential keys", () => { expect(collectRunFailureSecretValues({ CUSTOM_BINDING: "bound-opaque-value", ACCESS_TOKEN: "plain-opaque-value", REGION: "us-east-1", EMPTY_KEY: "", - }, ["CUSTOM_BINDING"])).toEqual(["bound-opaque-value", "plain-opaque-value", "us-east-1"]); - expect(collectRunFailureSecretValues({ PATH: "/usr/bin", CUSTOM_BINDING: "opaque" }, [], true)).toEqual(["opaque"]); - expect(collectRunFailureSecretValues({ PATH: "secret-override" }, ["PATH"], true)).toEqual(["secret-override"]); + }, ["CUSTOM_BINDING"])).toEqual(["bound-opaque-value", "plain-opaque-value"]); + expect(collectRunFailureSecretValues({ PATH: "/usr/bin", CUSTOM_BINDING: "opaque" })).toEqual(["opaque"]); + expect(collectRunFailureSecretValues({ PATH: "secret-override" }, ["PATH"])).toEqual(["secret-override"]); + }); + + it("keeps screenshot diagnostics intact when ordinary flags overlap their text", () => { + const diagnostic = collectRunFailureDiagnostics(run({ + runtimeMode: "native", driverKind: "codex_app_server", driverVersion: "phase6-v2", + startedAt: new Date("2026-10-19T19:18:20.617Z"), finishedAt: new Date("2026-10-19T19:21:20.385Z"), + runnerProfileJson: { nativeExecutionInput: { provider: { kind: "codex", model: "gpt-6.1-sol" } } }, + }), {}); + const input = { diagnostics: diagnostic, errorMessage: "Reconcile any uncertain external action before safely continuing this task." }; + const values = collectRunFailureSecretValues({ FEATURE_FLAG: "1", WORKER_COUNT: "2", MODE: "safe", REGION: "us-east-1" }); + expect(redactRunFailureSecretValues(input, values)).toEqual(input); + }); + + it("finds credentials by content and keeps short declared secrets private", () => { + const key = "sk-fixturecredential12345"; + const values = collectRunFailureSecretValues({ CUSTOM_SETTING: key, FEATURE_FLAG: "1", CUSTOM_BINDING: "2" }, ["CUSTOM_BINDING"]); + expect(values).toEqual([key, "2"]); + expect(redactRunFailureSecretValues(`key ${key} code 2`, values)).toBe("key ***REDACTED*** code ***REDACTED***"); }); it("redacts encoded credentials and URL passwords without expanding replacement markers", () => { @@ -565,12 +583,12 @@ describe("run failure diagnostics", () => { expect(result.text).not.toContain("***[REDACTED]"); }); - it("preserves known boolean and numeric settings without exempting unknown or declared secrets", () => { + it("preserves ordinary settings while retaining declared secrets", () => { const env = { PAPERCLIP_DB_BACKUP_ENABLED: "false", PAPERCLIP_DB_BACKUP_RETENTION_DAYS: "1" }; - expect(collectRunFailureSecretValues(env, [], true)).toEqual([]); - expect(collectRunFailureSecretValues(env, ["PAPERCLIP_DB_BACKUP_ENABLED"], true)).toEqual(["false"]); - expect(collectRunFailureSecretValues({ CUSTOM_BINDING: "1" }, [], true)).toEqual(["1"]); - expect(collectRunFailureSecretValues({ PAPERCLIP_DB_BACKUP_ENABLED: "opaque" }, [], true)).toEqual(["opaque"]); + expect(collectRunFailureSecretValues(env)).toEqual([]); + expect(collectRunFailureSecretValues(env, ["PAPERCLIP_DB_BACKUP_ENABLED"])).toEqual(["false"]); + expect(collectRunFailureSecretValues({ CUSTOM_BINDING: "1" })).toEqual(["1"]); + expect(collectRunFailureSecretValues({ PAPERCLIP_DB_BACKUP_ENABLED: "opaque" })).toEqual(["opaque"]); }); it("preserves generic exceptions, numeric codes and nested network causes", () => { const root = Object.assign(new Error("network failed"), { code: "ECONNRESET", statusCode: 502, request_id: "req-123" }); diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index 54bdd588786..f308bec5443 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -55,9 +55,8 @@ describeEmbeddedPostgres("reportRunFailure", () => { }, 20_000); beforeEach(() => { - // Unknown host values intentionally count as secrets, including short values - // such as "1". Keep these fixtures independent of the developer/CI environment - // and add secret values explicitly in the tests that exercise redaction. + // Keep fixtures independent of host credentials. Add credential values + // explicitly in the tests that exercise redaction. inheritedEnv = process.env; process.env = Object.fromEntries( ["PATH", "HOME", "USER", "USERNAME", "LOGNAME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"] @@ -1119,12 +1118,45 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(captured).not.toHaveProperty("secretValues"); }); - it("still redacts short values from unknown host settings", async () => { + it("masks an inherited Bedrock credential from every failure projection", async () => { await seedCompanyAndAgent(); - vi.stubEnv("FIXTURE_CUSTOM_VALUE", "1"); + const token = "opaque-bedrock-credential"; + vi.stubEnv("AWS_BEARER_TOKEN_BEDROCK", token); + const text = `upstream rejected ${token}`; + await reportRunFailure(db, buildRun({ error: text, resultJson: { terminalSessionFailure: { details: text } } }), { + error: new Error(text), adapterErrorMeta: { causeMessage: text }, + }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + const captured = mockCaptureRunFailure.mock.calls[0][0]; + expect(captured.errorMessage).toBe("upstream rejected ***REDACTED***"); + expect(captured.diagnostics.provider.details).toBe("upstream rejected ***REDACTED***"); + expect(JSON.stringify(captured)).not.toContain(token); + }); - await reportRunFailure(db, buildRun()); + it("keeps run metadata and prose readable with short non-secret host settings", async () => { + await seedCompanyAndAgent(); + vi.stubEnv("FIXTURE_FLAG", "1"); + vi.stubEnv("FIXTURE_WORKERS", "2"); + vi.stubEnv("FIXTURE_MODE", "safe"); + vi.stubEnv("SORT_KEY", "created_at"); + const error = "Column created_at is missing. Reconcile any uncertain external action before safely continuing this task."; + const run = buildRun({ error, runtimeMode: "native", driverKind: "codex_app_server", driverVersion: "phase6-v2", + startedAt: new Date("2026-10-19T19:18:20.617Z"), finishedAt: new Date("2026-10-19T19:21:20.385Z"), + runnerProfileJson: { nativeExecutionInput: { provider: { kind: "codex", model: "gpt-6.1-sol" } } }, + }); + await reportRunFailure(db, run); + + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + const captured = mockCaptureRunFailure.mock.calls[0][0]; + expect(captured.errorMessage).toBe(error); + expect(captured.diagnostics.execution).toMatchObject({ driverVersion: "phase6-v2", model: "gpt-6.1-sol", + startedAt: "2026-10-19T19:18:20.617Z", finishedAt: "2026-10-19T19:21:20.385Z" }); + }); + + it("still masks a short declared runtime secret", async () => { + await seedCompanyAndAgent(); + await reportRunFailure(db, buildRun(), { secretValues: ["1"] }); expect(mockCaptureRunFailure.mock.calls[0][0].errorMessage) .toBe(`the provider process exited with code ${REDACTED_EVENT_VALUE}`); }); diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index b1acdb40b2f..5d264a6549a 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -5,7 +5,7 @@ import { readControllerLeaseLoss, readControllerLeaseLossCause, readRunCancellat import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js"; import { readReviewedChatBindingDenialReason } from "./reviewed-chat-binding-diagnostics.js"; import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared"; -import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction"; +import { collectDiagnosticEnvironmentSecrets, redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction"; import { directoryMergeLockTimeoutDiagnosticsFor } from "@paperclipai/adapter-utils/directory-merge-lock-diagnostics"; import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; import { @@ -165,64 +165,8 @@ function scalars(value: unknown, fields: readonly string[]): Context { return result; } -// Unknown inherited values may contain credentials under arbitrary names. -// Explicitly configured values remain private, including overrides of these keys. -const PUBLIC_ENV_KEYS = new Set([ - "PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE", - "HOMEDRIVE", "HOMEPATH", "USER", "USERNAME", "LOGNAME", "SHELL", "LANG", - "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "NODE_ENV", - "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", - "PWD", "OLDPWD", "INIT_CWD", "SHLVL", "_", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM", - "NPM_LIFECYCLE_EVENT", "NPM_COMMAND", "NPM_EXEC_PATH", "NPM_EXECPATH", "NPM_NODE_EXECPATH", - "NPM_PACKAGE_NAME", "NPM_PACKAGE_VERSION", "NPM_PACKAGE_JSON", "NPM_CONFIG_USER_AGENT", - "XPC_SERVICE_NAME", "VITEST_POOL_ID", "VITEST_WORKER_ID", - "PAPERCLIP_AGENT_ID", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_TASK_ID", -]); -const PUBLIC_BOOLEAN_ENV_KEYS = new Set([ - "OPENCODE_ALLOW_ALL_MODELS", "CLAUDE_CODE_USE_BEDROCK", "GOOGLE_GENAI_USE_GCA", - "CI", "NO_COLOR", "FORCE_COLOR", - "VITEST", "MALLOCNANOZONE", "CODEX_CI", "CODEX_SHELL", "CODEX_SAGE_BACKFILL_TRACKER_TAB_REUSE", - "DEV", "PROD", "SSR", "PAPERCLIP_REQUIRE_SENTRY_TEST_SDK", - "PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_WORKSPACE_COMPACTION_ENABLED", "PAPERCLIP_AUTH_DISABLE_SIGN_UP", "PAPERCLIP_ENABLE_COMPANY_DELETION", - "PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "PAPERCLIP_SECRETS_STRICT_MODE", - "SERVE_UI", "PAPERCLIP_UI_DEV_MIDDLEWARE", "HEARTBEAT_SCHEDULER_ENABLED", "PAPERCLIP_ANNOUNCEMENTS_ENABLED", - "GITHUB_ACTIONS", "GITHUB_REF_PROTECTED", "RUNNER_DEBUG", "ACTIONS_RUNNER_DEBUG", "ACTIONS_STEP_DEBUG", -]); -const PUBLIC_NUMBER_ENV_KEYS = new Set([ - "PORT", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", "PAPERCLIP_DB_BACKUP_RETENTION_DAYS", - "PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "PAPERCLIP_WORKSPACE_COMPACTION_DELAY_HOURS", - "HEARTBEAT_SCHEDULER_INTERVAL_MS", - "GITHUB_RUN_ATTEMPT", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ID", "GITHUB_RETENTION_DAYS", -]); - -/** Include declared bindings and unknown values, not just credential-like keys. */ -export function collectRunFailureSecretValues( - env: unknown, secretKeys: Iterable = [], inherited = false, -): string[] { - if (!env || typeof env !== "object") return []; - const strings = Object.fromEntries(Object.entries(env).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - )); - const declared = new Set(secretKeys); - const values: string[] = []; - for (const [key, value] of Object.entries(strings)) { - if (!value) continue; - const publicSetting = (inherited && ( - PUBLIC_ENV_KEYS.has(key.toUpperCase()) || - (PUBLIC_NUMBER_ENV_KEYS.has(key.toUpperCase()) && /^\d+$/.test(value)) || - (key === "BASE_URL" && value === "/") || - (key === "MODE" && /^(?:test|development|production)$/.test(value)) || - (key === "PAPERCLIP_SENTRY_RUN_FAILURES" && /^(?:off|unexpected|all)$/.test(value)) - )) || - (PUBLIC_BOOLEAN_ENV_KEYS.has(key.toUpperCase()) && /^(?:0|1|true|false)$/i.test(value)); - if (declared.has(key) || !publicSetting) values.push(value); - try { - const password = new URL(value).password; - if (password) values.push(value, password, decodeURIComponent(password)); - } catch { /* ordinary environment values are not URLs */ } - } - return [...new Set(values)].sort((a, b) => b.length - a.length); -} +/** Select credential fields, credential-shaped values, and declared bindings. */ +export const collectRunFailureSecretValues = collectDiagnosticEnvironmentSecrets; /** Redact literal, JSON-escaped and URI-encoded forms without rewriting markers. */ export function redactRunFailureSecretValues(input: T, values: readonly string[]): T { diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index c553ad3db47..dc210833b55 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -359,7 +359,7 @@ async function captureTerminalRunFailure( errorCode: run.errorCode ?? null, diagnostics: collectRunFailureDiagnostics(run, options), }, [...new Set([ - ...collectRunFailureSecretValues(process.env, [], true), + ...collectRunFailureSecretValues(process.env), ...(options.secretValues ?? []), ])].sort((a, b) => b.length - a.length)); const agent = await db