diff --git a/apps/electron/src/main/credentialStore.ts b/apps/electron/src/main/credentialStore.ts index 0abf6fe..3b4123a 100644 --- a/apps/electron/src/main/credentialStore.ts +++ b/apps/electron/src/main/credentialStore.ts @@ -9,6 +9,7 @@ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { safeStorage } from 'electron'; import type { CredentialInfo, CustomProviderConfig } from '@finagent/core'; +import { redactText } from '@finagent/shared/privacy'; export interface StoredCredential { apiKey: string; @@ -29,15 +30,14 @@ interface StoreShape { customProviders: Record; } -const REDACTED = '[REDACTED]'; - -/** Remove secret material from an arbitrary message string. */ +/** + * Remove secret material from an arbitrary message string. Delegates to the + * shared privacy rule source (issue #19) so credential-store errors get the + * same coverage as logs, diagnostics and telemetry — cookies, connection + * strings, URL query secrets included, not just sk-/JWT shapes. + */ export function redactSecrets(message: string): string { - // Strip anything that looks like a key: sk-..., long bearer tokens. - return message - .replace(/\b(sk|rk|pk|ak)-[A-Za-z0-9_\-]{8,}\b/gi, REDACTED) - .replace(/("apiKey"\s*:\s*")[^"]{8,}(")/g, `$1${REDACTED}$2`) - .replace(/\beyJ[A-Za-z0-9_\-]{20,}\b/g, REDACTED); + return redactText(message); } export class CredentialStore { diff --git a/apps/electron/src/main/kernelHost.ts b/apps/electron/src/main/kernelHost.ts index 2b4bed1..7ae0440 100644 --- a/apps/electron/src/main/kernelHost.ts +++ b/apps/electron/src/main/kernelHost.ts @@ -181,6 +181,7 @@ import { type DiagnosticsBundle, type FinancialProviderSummary, } from '@finagent/shared/diagnostics'; +import { redactError } from '@finagent/shared/privacy'; import { CredentialStore, redactSecrets } from './credentialStore.ts'; import { executeLongBridge } from '@finagent/longbridge-tools'; @@ -905,7 +906,8 @@ export class AgentKernelHost { this.window?.webContents.send('thesis:impact', impact); } catch (error) { // An alert must never crash the engine tick; the trigger is already logged. - console.error('thesis impact evaluation failed:', error); + const { message } = redactError(error); + console.error('thesis impact evaluation failed:', message); } } @@ -1338,7 +1340,11 @@ export class AgentKernelHost { answer: this.evaluationRedactor.redactAnswer(pending.answer), toolCalls: toolCalls.map((toolCall) => this.evaluationRedactor.redactToolCall(toolCall)), failureModes: [], - error: pending.error, + // Run errors are persisted to eval artifacts — redact at the boundary + // (issue #19); failure messages echo provider responses verbatim. + error: pending.error + ? { ...pending.error, message: redactSecrets(pending.error.message) } + : pending.error, }; try { await this.evaluationStore.addRun(run); diff --git a/docs/privacy-redaction.md b/docs/privacy-redaction.md new file mode 100644 index 0000000..35bdb9c --- /dev/null +++ b/docs/privacy-redaction.md @@ -0,0 +1,85 @@ +# Privacy: Redaction & Telemetry Content Policy + +Folio is local-first: sessions, credentials and research state stay on the +device by default. This document defines what happens whenever data *does* +leave the core runtime — kernel logs, diagnostics bundles, LangSmith/Langfuse +telemetry, evaluation artifacts, IPC error payloads, and report exports. + +All rules live in one place: `packages/shared/src/privacy/` (issue #19). New +outbound boundaries must import from `@finagent/shared/privacy` instead of +growing their own regex lists. + +## The single rule source + +| Module | Responsibility | +| --- | --- | +| `privacy/redact-text.ts` | `redactText(text)` — string-level secret patterns (fail-closed) | +| `privacy/deep-redact.ts` | `deepRedact(value)` — deep JSON walk with secret field names, cycle/depth guards; `redactError(err)` — message + stack serialization | +| `privacy/policy.ts` | Field-name rules, account-like key shapes, `REDACTION_POLICY`, telemetry content policy | + +Legacy entry points (`diagnostics/redact`, `evaluation/redactor`, +`export/privacy`, the main process `redactSecrets`) delegate to this module, +so every boundary redacts with the same rules. + +## What is always redacted + +No matter the privacy level, the following never leave the machine in logs, +traces, diagnostics or eval artifacts: + +- Provider API keys (`sk-…`, `sk-ant-…`, `rk-/pk-/ak-…`, Google `AIza…`, Slack + `xox…`, SendGrid `SG.…`, GitLab `glpat-…`, npm `npm_…`, LangSmith `lsv2_…`) +- AWS access keys (`AKIA…`) and secret access keys in key/value context +- `Authorization` headers (Bearer and Basic), `x-api-key` / `apiKey` / + `api_key` fields +- JWTs and VCS tokens (`gh*…`, `github_pat_…`) +- Cookies and session tokens (`Cookie`, `Set-Cookie`, `session_token`) +- Connection-string credentials (`postgres://user:pass@…`, `redis://:pass@…`, + `mongodb+srv://…`) — scheme and host are kept, credentials dropped +- Webhook secrets and signatures (`whsec_…`, `x-hub-signature…`) +- Private key PEM blocks +- Secrets in URL query strings and fragments (`?apikey=…`, `#token=…`) +- Long base64-ish blobs (≥40 chars with mixed case) + +Redaction is **fail-closed**: if the walk hits a cycle, excessive nesting, or +an internal error, the offending node is replaced with `[REDACTED]` — a raw +payload is never emitted as a fallback. Redaction is also idempotent and +preserves observability fields (run ids, trace ids, tool names, statuses, +latencies, timestamps). + +## Telemetry content policy (data minimization) + +Content minimization is layered on top of secret redaction and selected via +the evaluation settings privacy level (`evaluation.privacyLevel`, persisted in +the evaluation store): + +| Level | What is recorded | Use when | +| --- | --- | --- | +| `minimal` | Names, statuses, durations, counts only — no prompts, answers, or tool args/results | Most paranoid setup; still enough for latency/status dashboards | +| `standard` (default) | Prompts/answers/tool args after redaction; portfolio tool results reduced to schema summaries (shape + counts, never holdings/cash/account ids) | Default for all users | +| `full` | Complete trace content, still credential-redacted | Debugging a specific run — explicit opt-in only | + +The Pi agent runtime reads the same level from the `FINAGENT_PRIVACY_LEVEL` +environment variable (`minimal|standard|full`); unknown or unset values mean +tool output keeps its raw DATA blocks locally (nothing is uploaded anyway +unless tracing is enabled). + +Full-content tracing is **never** the default. To opt in for a debugging +session, set the evaluation privacy level to `full` in settings (or export +`FINAGENT_PRIVACY_LEVEL=full` before launching) and revert afterwards. + +## Boundaries that apply these rules + +- **Kernel/main-process logs** — `console.error` sites serialize via + `redactError`; the main error ring buffer (`ErrorLog`) redacts message and + stack at collection time, so stack first lines cannot echo raw error text. +- **Diagnostics support bundle** — `serializeSupportBundle` re-applies + `redactText` to every string and stamps `redaction.applied`. +- **Evaluation artifacts** (`store.json`, run records, judge results) — + answers/tool calls via `EvaluationRedactor` per privacy level; run and judge + error messages are redacted before persistence. +- **IPC error payloads** — `toIpcError` redacts messages before they cross to + the renderer. +- **Report export/share** — `redactForShare` additionally strips account-like + numeric fields; prose and evidence pass through. +- **Credential store** — all error paths redact via the shared engine; the + store itself only ever returns metadata to the renderer. diff --git a/docs/privacy-redaction.zh-CN.md b/docs/privacy-redaction.zh-CN.md new file mode 100644 index 0000000..7fb3fbb --- /dev/null +++ b/docs/privacy-redaction.zh-CN.md @@ -0,0 +1,55 @@ +# 隐私:脱敏与 Telemetry 内容策略 + +Folio 本地优先:会话、凭证与研究状态默认保存在设备上。本文档定义当数据**确实**要离开核心运行时——内核日志、诊断包、LangSmith/Langfuse telemetry、评测产物、IPC 错误负载、报告导出——时的处理规则。 + +所有规则只有一个来源:`packages/shared/src/privacy/`(issue #19)。新的数据出口必须从 `@finagent/shared/privacy` 导入,而不是各自维护正则列表。 + +## 统一规则源 + +| 模块 | 职责 | +| --- | --- | +| `privacy/redact-text.ts` | `redactText(text)` —— 字符串级 secret 模式匹配(fail-closed) | +| `privacy/deep-redact.ts` | `deepRedact(value)` —— 基于敏感字段名的深层遍历,带环/深度保护;`redactError(err)` —— 消息 + 堆栈序列化 | +| `privacy/policy.ts` | 字段名规则、账户类键形、`REDACTION_POLICY`、telemetry 内容策略 | + +旧入口(`diagnostics/redact`、`evaluation/redactor`、`export/privacy`、主进程 `redactSecrets`)均委托到该模块,因此所有出口使用同一套规则。 + +## 始终脱敏的内容 + +无论隐私级别如何,以下内容绝不出现在日志、trace、诊断或评测产物中: + +- 供应商 API key(`sk-…`、`sk-ant-…`、`rk-/pk-/ak-…`、Google `AIza…`、Slack `xox…`、SendGrid `SG.…`、GitLab `glpat-…`、npm `npm_…`、LangSmith `lsv2_…`) +- AWS 访问密钥(`AKIA…`)及 key/value 上下文中的 secret access key +- `Authorization` 头(Bearer 与 Basic)、`x-api-key` / `apiKey` / `api_key` 字段 +- JWT 与版本控制系统 token(`gh*…`、`github_pat_…`) +- Cookie 与 session token(`Cookie`、`Set-Cookie`、`session_token`) +- 连接串中的凭证(`postgres://user:pass@…`、`redis://:pass@…`、`mongodb+srv://…`)——保留协议与主机,去掉凭证 +- Webhook secret 与签名(`whsec_…`、`x-hub-signature…`) +- 私钥 PEM 块 +- URL query 与 fragment 中的 secret(`?apikey=…`、`#token=…`) +- 较长的 base64 块(≥40 字符且含大小写) + +脱敏是 **fail-closed** 的:遍历遇到循环引用、过深嵌套或内部错误时,该节点直接替换为 `[REDACTED]`——绝不会以原始负载作为回退输出。脱敏具有幂等性,且保留可观测性字段(run id、trace id、工具名、状态、耗时、时间戳)。 + +## Telemetry 内容策略(数据最小化) + +内容最小化叠加在 secret 脱敏之上,通过评测设置的隐私级别(`evaluation.privacyLevel`,持久化在 evaluation store)选择: + +| 级别 | 记录内容 | 适用场景 | +| --- | --- | --- | +| `minimal` | 仅名称、状态、耗时、计数——不记录 prompt、回答、工具参数/结果 | 最保守的配置;仍足以支撑时延/状态面板 | +| `standard`(默认) | 脱敏后的 prompt/回答/工具参数;组合类工具结果降级为 schema 摘要(形状 + 计数,绝不含持仓/现金/账户 id) | 所有用户的默认值 | +| `full` | 完整 trace 内容,仍然脱敏凭证 | 调试特定 run——仅显式 opt-in | + +Pi agent 运行时从 `FINAGENT_PRIVACY_LEVEL` 环境变量(`minimal|standard|full`)读取同一级别;未知或未设置时工具输出在本地保留原始 DATA 块(未开启 tracing 时本就不会上传任何内容)。 + +**完整内容 tracing 绝不是默认值。** 调试需要时,在设置中将评测隐私级别改为 `full`(或在启动前 `export FINAGENT_PRIVACY_LEVEL=full`),调试完成后改回。 + +## 应用这些规则的出口 + +- **内核/主进程日志** —— `console.error` 处经由 `redactError` 序列化;主进程错误环形缓冲(`ErrorLog`)在采集时即脱敏消息与堆栈,堆栈首行不会回显原始错误文本。 +- **诊断支持包** —— `serializeSupportBundle` 对每个字符串重新应用 `redactText` 并标记 `redaction.applied`。 +- **评测产物**(`store.json`、run 记录、judge 结果)—— 回答/工具调用按隐私级别经 `EvaluationRedactor` 处理;run 与 judge 的错误消息在落盘前脱敏。 +- **IPC 错误负载** —— `toIpcError` 在跨进程序列化前脱敏消息。 +- **报告导出/分享** —— `redactForShare` 额外剔除账户类数值字段;正文与证据原样通过。 +- **凭证存储** —— 所有错误路径经统一引擎脱敏;存储本身只向渲染进程返回元数据。 diff --git a/packages/shared/package.json b/packages/shared/package.json index c97b69e..b564b88 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,7 +15,8 @@ "./portfolio-import": "./src/portfolio-import/index.ts", "./diagnostics": "./src/diagnostics/index.ts", "./providers/longbridge": "./src/providers/longbridge/index.ts", - "./providers/massive": "./src/providers/massive/index.ts" + "./providers/massive": "./src/providers/massive/index.ts", + "./privacy": "./src/privacy/index.ts" }, "dependencies": { "@finagent/core": "workspace:*", diff --git a/packages/shared/src/diagnostics/error-log.test.ts b/packages/shared/src/diagnostics/error-log.test.ts index bb459c9..44b5ecf 100644 --- a/packages/shared/src/diagnostics/error-log.test.ts +++ b/packages/shared/src/diagnostics/error-log.test.ts @@ -41,4 +41,18 @@ describe('ErrorLog', () => { expect(log.size).toBe(0); expect(log.recent(5)).toEqual([]); }); + + it('redacts messages and stacks at collection time (issue #19)', () => { + const log = new ErrorLog({ now: () => 0 }); + log.push({ + message: 'Request failed: Authorization: Bearer abcdefghijklmnop123', + stack: + 'Error: Request failed: cookie: session=deadbeef1234\n at fetch (client.ts:1:1)', + }); + const [entry] = log.recent(1); + expect(entry.message).not.toContain('abcdefghijklmnop123'); + expect(entry.message).toContain('[REDACTED]'); + expect(entry.stack).not.toContain('deadbeef1234'); + expect(entry.stack).toContain('at fetch (client.ts:1:1)'); + }); }); diff --git a/packages/shared/src/diagnostics/error-log.ts b/packages/shared/src/diagnostics/error-log.ts index c1fc067..1bf3c8d 100644 --- a/packages/shared/src/diagnostics/error-log.ts +++ b/packages/shared/src/diagnostics/error-log.ts @@ -1,4 +1,5 @@ import type { ErrorLogEntry } from './types.ts'; +import { redactText } from '../privacy/redact-text.ts'; export interface ErrorLogOptions { /** Maximum retained entries; older entries are evicted. Defaults to 50. */ @@ -13,6 +14,10 @@ export interface ErrorLogOptions { * `push` normalizes each entry (no `undefined` fields) and evicts the oldest * once capacity is exceeded. `recent(n)` returns the newest `n` entries, * newest first — the order the Diagnostics UI shows them in. + * + * Messages and stacks are redacted at collection time (issue #19): stack + * first lines echo the raw error message, which may carry Authorization + * headers, signed URLs or connection strings from the failing call. */ export class ErrorLog { private readonly capacity: number; @@ -33,8 +38,8 @@ export class ErrorLog { const normalized: ErrorLogEntry = { at: entry.at ?? this.now(), source: entry.source ?? null, - message: entry.message, - stack: entry.stack ?? null, + message: redactText(entry.message), + stack: entry.stack ? redactText(entry.stack) : null, }; this.entries.push(normalized); if (this.entries.length > this.capacity) { diff --git a/packages/shared/src/diagnostics/redact.ts b/packages/shared/src/diagnostics/redact.ts index 463b750..3e97e52 100644 --- a/packages/shared/src/diagnostics/redact.ts +++ b/packages/shared/src/diagnostics/redact.ts @@ -1,50 +1,13 @@ /** * Secret redaction for diagnostics exports (spec §36). * - * Applied before serialization so API keys, OAuth tokens, raw credentials, - * and base64-ish blobs can never leave the machine. Private conversation - * contents and portfolio details are never collected in the first place — - * redaction is the last line of defense for anything that slips through in a - * message/stack string. + * The implementation now lives in the shared privacy module (issue #19) so + * logs, diagnostics, telemetry and eval artifacts redact with the same rule + * source. Applied before serialization so API keys, OAuth tokens, raw + * credentials, cookies, connection strings and base64-ish blobs can never + * leave the machine. Private conversation contents and portfolio details are + * never collected in the first place — redaction is the last line of defense + * for anything that slips through in a message/stack string. */ - -const REDACTED = '[REDACTED]'; - -export const REDACTION_POLICY = - 'Strips API keys (sk-/rk-/pk-/ak-…), AWS access keys (AKIA…), Bearer and ' + - 'X-Api-Key/Authorization tokens, JWTs, VCS tokens (gh*/github_pat_), ' + - 'LangSmith keys (lsv2_pt_/lsv2_sk_…), and base64-ish blobs. Private ' + - 'conversation contents and portfolio details are never collected.'; - -type Replacement = string; - -const PATTERNS: ReadonlyArray = [ - // OpenAI/Anthropic-style keys: sk-…, sk-ant-…, rk-/pk-/ak-… (dash or not). - [/\b(?:sk-ant-|sk-|rk-|pk-|ak-)[A-Za-z0-9_-]{8,}\b/g, REDACTED], - // AWS access key ids (20 uppercase alphanumeric chars prefixed with AKIA). - [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED], - // Bearer auth headers: keep the scheme, redact the token. - [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/g, `$1${REDACTED}`], - // API-key headers / JSON fields: x-api-key, X-Api-Key, apiKey, api_key. - [/(["']?(?:x-api-key|X-Api-Key|api[_-]?key)["']?\s*[:=]\s*["']?)[A-Za-z0-9._~+/=-]{8,}["']?/g, `$1${REDACTED}`], - // JWTs (header.payload.signature). - [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, REDACTED], - // GitHub / common VCS tokens. - [/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9]{8,}\b/g, REDACTED], - // LangSmith API keys: lsv2_pt_/lsv2_sk_ prefixes, or a bare lsv2_ + 8+ chars. - // The suffix is hex, which the base64 pattern below deliberately skips, and - // contains underscores, which the api-key header pattern cannot span. - [/\blsv2_(?:pt_|sk_)?[A-Za-z0-9]{8,}\b/g, REDACTED], - // Base64-ish blobs: long runs (≥40 chars) that include an uppercase char, - // which excludes lowercase git SHAs and hex identifiers. - [/\b(?=[A-Za-z0-9+/]{40,}={0,2})(?=[A-Za-z0-9+/]*[A-Z])[A-Za-z0-9+/]{40,}={0,2}/g, REDACTED], -]; - -/** Strip secret-shaped material from a string, preserving surrounding text. */ -export function redact(text: string): string { - let out = text; - for (const [pattern, replacement] of PATTERNS) { - out = out.replace(pattern, replacement); - } - return out; -} +export { redactText as redact } from '../privacy/redact-text.ts'; +export { REDACTION_POLICY } from '../privacy/policy.ts'; diff --git a/packages/shared/src/evaluation/evaluator.ts b/packages/shared/src/evaluation/evaluator.ts index cad5a2e..48b517a 100644 --- a/packages/shared/src/evaluation/evaluator.ts +++ b/packages/shared/src/evaluation/evaluator.ts @@ -14,6 +14,7 @@ import type { EvaluationSettings, ToolCallRecord, } from '@finagent/core'; +import { redactError } from '../privacy/deep-redact.ts'; export interface EvaluationContext { case: EvaluationCase; @@ -97,7 +98,10 @@ export class EvaluatorRegistry { metric: definition.metric, metricVersion: definition.version, score: null, - reason: error instanceof Error ? error.message : String(error), + // Judge failures echo provider HTTP bodies (401/429 with key + // material) — scores persist to eval artifacts, so redact here + // (issue #19). + reason: redactError(error).message, }); } } diff --git a/packages/shared/src/evaluation/redactor.ts b/packages/shared/src/evaluation/redactor.ts index 6e7b6c8..31959e4 100644 --- a/packages/shared/src/evaluation/redactor.ts +++ b/packages/shared/src/evaluation/redactor.ts @@ -12,32 +12,8 @@ // latency) — never raw holdings/positions/cash/account ids (spec §60). // - full: complete trace, still credential-redacted. Explicit opt-in only. import type { PrivacyLevel, ToolCallRecord } from '@finagent/core'; -import { redact as redactText } from '../diagnostics/redact.ts'; - -const REDACTED = '[REDACTED]'; - -/** Field names treated as credential-bearing and always redacted. */ -const SECRET_FIELD_NAMES: Record = { - apikey: true, - api_key: true, - 'x-api-key': true, - authorization: true, - cookie: true, - cookies: true, - secret: true, - password: true, - passphrase: true, - token: true, - access_token: true, - refresh_token: true, - id_token: true, - credential: true, - credentials: true, - client_secret: true, - privatekey: true, - private_key: true, - auth: true, -}; +import { REDACTED, isSecretField } from '../privacy/policy.ts'; +import { redactText } from '../privacy/redact-text.ts'; /** * Portfolio-sensitive tool names. Full payloads never leave the machine below @@ -65,14 +41,6 @@ export interface RedactionResult { portfolioDowngraded: boolean; } -function isSecretField(path: string): boolean { - const leaf = path.split('.').pop()?.toLowerCase() ?? ''; - if (SECRET_FIELD_NAMES[leaf]) return true; - // Generic credential-like suffixes: apiKey, apiKeyV2, signingKey, accessToken, - // password (history-safe: payload fields that end in these are credential-ish). - return /(key|secret|token|password|passwd|bearer)$/.test(leaf) || leaf.startsWith('api_'); -} - export class EvaluationRedactor { private readonly privacyLevel: PrivacyLevel; diff --git a/packages/shared/src/export/privacy.ts b/packages/shared/src/export/privacy.ts index 52f0bdf..736a6c7 100644 --- a/packages/shared/src/export/privacy.ts +++ b/packages/shared/src/export/privacy.ts @@ -1,4 +1,5 @@ import type { ResearchReport } from '@finagent/core' +import { ACCOUNT_LIKE_KEY, isNumeric } from '../privacy/policy.ts' /** * Privacy hardening for share/export (spec §55). @@ -13,15 +14,8 @@ import type { ResearchReport } from '@finagent/core' * report content pass through untouched. */ -/** Key shapes that may carry account/position/portfolio data. */ -export const ACCOUNT_LIKE_KEY = - /(account|position|portfolio|holding|balance|equity|assets|netasset|net_asset|nav)/i - -const NUMERIC_STRING = /^-?\d+(\.\d+)?$/ - -function isNumeric(value: unknown): boolean { - return typeof value === 'number' || (typeof value === 'string' && NUMERIC_STRING.test(value)) -} +/** Key shapes that may carry account/position/portfolio data. (Re-exported from the shared privacy policy, issue #19.) */ +export { ACCOUNT_LIKE_KEY } function redact(value: unknown): unknown { if (Array.isArray(value)) return value.map(redact) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 11cb604..760b34e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -152,6 +152,7 @@ export * from './automation/index.ts'; export * from './performance/index.ts'; export * from './calibration/index.ts'; export * from './pulse/index.ts'; +export * from './privacy/index.ts'; export * from './export/index.ts'; export * from './evidence/index.ts'; export * from './evaluation/index.ts'; diff --git a/packages/shared/src/privacy/deep-redact.test.ts b/packages/shared/src/privacy/deep-redact.test.ts new file mode 100644 index 0000000..d121f3b --- /dev/null +++ b/packages/shared/src/privacy/deep-redact.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'bun:test'; +import { deepRedact, redactError } from './deep-redact.ts'; + +describe('deepRedact', () => { + it('redacts secret fields at any nesting depth in tool args/results', () => { + const toolArgs = { + symbol: 'AAPL.US', + auth: { + apiKey: 'sk-abcdef1234567890', + }, + request: { + headers: { + Authorization: 'Bearer abcdefghijklmnop123456', + 'x-api-key': 'rawkey987654321', + }, + }, + options: [{ refresh_token: 'tokenabcdef123456' }, { limit: 10 }], + }; + const { value } = deepRedact(toolArgs); + const out = value as typeof toolArgs; + // `auth` itself is a secret field name — the whole subtree goes. + expect(out.auth as unknown).toBe('[REDACTED]'); + expect(out.request.headers.Authorization).toBe('[REDACTED]'); + expect(out.request.headers['x-api-key']).toBe('[REDACTED]'); + expect(out.options[0].refresh_token).toBe('[REDACTED]'); + // Non-secret content is preserved. + expect(out.symbol).toBe('AAPL.US'); + expect(out.options[1].limit).toBe(10); + }); + + it('redacts secret-shaped text inside nested values (URL query, keys)', () => { + const payload = { + request: { url: 'https://api.example.com/v1?symbol=AAPL&apikey=abcdef123456789' }, + note: 'provider said: sk-abcdef1234567890 rejected', + }; + const { value } = deepRedact(payload); + const out = value as typeof payload; + expect(out.request.url).toContain('symbol=AAPL'); + expect(out.request.url).not.toContain('abcdef123456789'); + expect(out.note).not.toContain('sk-abcdef1234567890'); + }); + + it('keeps observability fields intact (run id, tool name, latency, status)', () => { + const trace = { + runId: '1a2b3c4d-5678-4ef0-ab12-cd34ef56ab78', + threadId: 'sess_1a2b3c4d5e6f7g8h', + toolName: 'market.quote', + status: 'completed', + latencyMs: 1234, + startedAt: 1726000000000, + provider: 'anthropic', + model: 'claude-3-5-sonnet-20241022', + }; + const { value, redactedPaths } = deepRedact(trace); + expect(value).toEqual(trace); + expect(redactedPaths).toEqual([]); + }); + + it('fail-closed: cyclic structures become REDACTED instead of throwing', () => { + const cyclic: Record = { apiKey: 'sk-abcdef1234567890' }; + cyclic.self = cyclic; + const { value } = deepRedact(cyclic); + const out = value as Record; + expect(out.apiKey).toBe('[REDACTED]'); + expect(out.self).toBe('[REDACTED]'); + }); + + it('fail-closed: over-deep structures are cut off with REDACTED', () => { + let deep: unknown = { secret: 'x' }; + for (let i = 0; i < 60; i += 1) deep = { nested: deep }; + expect(() => deepRedact(deep)).not.toThrow(); + const { value } = deepRedact(deep); + expect(JSON.stringify(value)).toContain('[REDACTED]'); + expect(JSON.stringify(value)).not.toContain('"secret":"x"'); + }); + + it('reports redacted field paths without values', () => { + const { redactedPaths } = deepRedact({ a: { client_secret: 'supersecret123' } }); + expect(redactedPaths).toContain('a.client_secret'); + }); + + it('never mutates the input', () => { + const input = { apiKey: 'sk-abcdef1234567890', url: 'https://x.io?token=abcdefgh1234' }; + const snapshot = JSON.stringify(input); + deepRedact(input); + expect(JSON.stringify(input)).toBe(snapshot); + }); +}); + +describe('redactError', () => { + it('redacts message and stack of a thrown error', () => { + const error = new Error('Request failed: Authorization: Bearer abcdefghijklmnop123'); + error.stack = `${error.name}: ${error.message}\n at fetch (client.ts:1:1)`; + const out = redactError(error); + expect(out.message).not.toContain('abcdefghijklmnop123'); + expect(out.message).toContain('[REDACTED]'); + expect(out.stack).not.toContain('abcdefghijklmnop123'); + expect(out.stack).toContain('at fetch (client.ts:1:1)'); + }); + + it('handles non-Error throwables', () => { + const out = redactError('401: bad api_key=supersecretvalue99'); + expect(out.message).not.toContain('supersecretvalue99'); + expect(out.stack).toBeNull(); + }); + + it('is fail-closed on exotic inputs', () => { + // A toString that throws exercises the fail-closed path. + const hostile = { + toString(): string { + throw new Error('boom'); + }, + }; + const out = redactError(hostile); + expect(out.message).toBe('[REDACTED]'); + expect(out.stack).toBeNull(); + }); +}); diff --git a/packages/shared/src/privacy/deep-redact.ts b/packages/shared/src/privacy/deep-redact.ts new file mode 100644 index 0000000..3d4551b --- /dev/null +++ b/packages/shared/src/privacy/deep-redact.ts @@ -0,0 +1,106 @@ +// Deep value redaction and error serialization on top of the shared policy +// (issue #19). Used where structured payloads — tool args/results, evaluation +// records, telemetry metadata — cross an outbound boundary. Fail-closed: a +// walk that goes wrong (cycles, depth, exotic objects) yields REDACTED nodes, +// never the raw input. +import { isSecretField, REDACTED } from './policy.ts'; +import { redactText } from './redact-text.ts'; + +/** Defensive cap: payloads nested deeper than this are redacted wholesale. */ +const MAX_DEPTH = 32; + +export interface DeepRedactResult { + /** Structurally safe copy of the input; never the raw input. */ + value: unknown; + /** Field paths whose values were replaced (for diagnostics, never values). */ + redactedPaths: string[]; +} + +export interface DeepRedactOptions { + /** + * Extra secret-field predicate layered onto the shared field-name rules, + * e.g. boundary-specific key shapes the shared policy does not know. + */ + isSecretField?: (path: string) => boolean; +} + +/** + * Deep-redact an arbitrary JSON-ish value: credential-named fields become + * REDACTED, every string passes through `redactText`. Cyclic references and + * over-deep structures are replaced with REDACTED instead of throwing — the + * raw payload is never returned as a fallback. + */ +export function deepRedact(value: unknown, options: DeepRedactOptions = {}): DeepRedactResult { + const paths: string[] = []; + const seen = new WeakSet(); + const isSecret = (path: string): boolean => + isSecretField(path) || (options.isSecretField?.(path) ?? false); + + const walk = (node: unknown, path: string, depth: number): unknown => { + if (typeof node === 'string') { + if (path && isSecret(path)) { + paths.push(path); + return REDACTED; + } + const cleaned = redactText(node); + if (cleaned !== node) paths.push(path); + return cleaned; + } + if (typeof node !== 'object' || node === null) return node; + if (depth >= MAX_DEPTH) { + paths.push(path || ''); + return REDACTED; + } + if (seen.has(node)) { + paths.push(path || ''); + return REDACTED; + } + seen.add(node); + try { + if (Array.isArray(node)) { + return node.map((item, index) => walk(item, `${path}[${index}]`, depth + 1)); + } + const record = node as Record; + const out: Record = {}; + for (const [key, child] of Object.entries(record)) { + const childPath = path ? `${path}.${key}` : key; + if (isSecret(childPath)) { + paths.push(childPath); + out[key] = REDACTED; + continue; + } + out[key] = walk(child, childPath, depth + 1); + } + return out; + } catch { + paths.push(path || ''); + return REDACTED; + } + }; + + return { value: walk(value, '', 0), redactedPaths: paths }; +} + +export interface RedactedError { + message: string; + stack: string | null; +} + +/** + * Serialize an unknown thrown value with message and stack redacted. This is + * the boundary rule for error logs, IPC error payloads and diagnostics — + * exception text frequently echoes Authorization headers, signed URLs or + * connection strings from the failing HTTP call. + */ +export function redactError(error: unknown): RedactedError { + try { + const message = redactText(error instanceof Error ? error.message : String(error)); + const stack = error instanceof Error && typeof error.stack === 'string' + ? redactText(error.stack) + : null; + return { message, stack }; + } catch { + // Fail closed: never serialize a raw error we could not redact. + return { message: REDACTED, stack: null }; + } +} diff --git a/packages/shared/src/privacy/index.ts b/packages/shared/src/privacy/index.ts new file mode 100644 index 0000000..af846a3 --- /dev/null +++ b/packages/shared/src/privacy/index.ts @@ -0,0 +1,20 @@ +// Unified redaction / data-minimization contract (issue #19). +// +// One rule source for every boundary where data leaves the core runtime: +// kernel logs, diagnostics bundles, LangSmith/Langfuse telemetry, eval +// artifacts, IPC error serialization and report exports. The modules under +// `diagnostics/`, `evaluation/` and `export/` re-export or delegate to this +// module; new boundaries must import from here rather than growing their own +// regex lists. +export { + ACCOUNT_LIKE_KEY, + DEFAULT_TELEMETRY_CONTENT_POLICY, + isNumeric, + isSecretField, + REDACTED, + REDACTION_POLICY, + SECRET_FIELD_NAMES, +} from './policy.ts'; +export { redactText } from './redact-text.ts'; +export { deepRedact, redactError } from './deep-redact.ts'; +export type { DeepRedactOptions, DeepRedactResult, RedactedError } from './deep-redact.ts'; diff --git a/packages/shared/src/privacy/policy.ts b/packages/shared/src/privacy/policy.ts new file mode 100644 index 0000000..cc29a65 --- /dev/null +++ b/packages/shared/src/privacy/policy.ts @@ -0,0 +1,107 @@ +// Single rule source for outbound-data redaction and data minimization +// (issue #19). Every boundary that lets data leave the core runtime — kernel +// logs, the diagnostics bundle, LangSmith/Langfuse payloads, eval artifacts, +// IPC error serialization, report exports — derives its rules from here +// instead of keeping a private regex list. + +/** Marker substituted for every redacted secret. */ +export const REDACTED = '[REDACTED]'; + +/** + * Human-readable summary of the string-level redaction policy. Surfaced in + * the diagnostics bundle (`redaction.policy`) so support bundles state what + * was stripped. + */ +export const REDACTION_POLICY = + 'Strips API keys (sk-/rk-/pk-/ak-…), AWS keys (AKIA…), Bearer and Basic auth ' + + 'headers, x-api-key/apiKey fields, JWTs, VCS tokens (gh*/github_pat_/' + + 'glpat-/npm_), cloud & SaaS tokens (lsv2_/xox…/SG./AIza…/ya29.), cookies & ' + + 'session tokens, webhook signatures (whsec_/x-hub-signature), private key ' + + 'PEM blocks, URL userinfo and secret query parameters, connection-string ' + + 'credentials, and base64-ish blobs. Private conversation contents and ' + + 'portfolio details are additionally minimized per privacy level.'; + +/** + * Field names treated as credential-bearing: their values are replaced with + * REDACTED regardless of content shape (spec §56-60). + */ +export const SECRET_FIELD_NAMES: Record = { + apikey: true, + api_key: true, + 'x-api-key': true, + apisecret: true, + api_secret: true, + secretkey: true, + secret_key: true, + authorization: true, + proxyauthorization: true, + cookie: true, + cookies: true, + 'set-cookie': true, + sessiontoken: true, + session_token: true, + secret: true, + password: true, + passwd: true, + passphrase: true, + token: true, + accesstoken: true, + access_token: true, + refreshtoken: true, + refresh_token: true, + id_token: true, + credential: true, + credentials: true, + clientsecret: true, + client_secret: true, + privatekey: true, + private_key: true, + auth: true, + webhooksecret: true, + webhook_secret: true, + sharedsecret: true, + signingkey: true, + signing_key: true, +}; + +/** Field-name suffixes that mark a field as credential-like. */ +const SECRET_FIELD_SUFFIX = /(key|secret|token|password|passwd|bearer|credential)$/; + +/** + * True when a dotted object path points at a credential-bearing field. The + * leaf segment decides; `apiKeyV2`, `signingKey`, `access_token` all match + * (history-safe: payload fields that end in these are credential-ish). + */ +export function isSecretField(path: string): boolean { + const leaf = path.split('.').pop()?.split('[')[0]?.toLowerCase() ?? ''; + if (SECRET_FIELD_NAMES[leaf]) return true; + return SECRET_FIELD_SUFFIX.test(leaf) || leaf.startsWith('api_'); +} + +/** Key shapes that may carry account/position/portfolio data (spec §55). */ +export const ACCOUNT_LIKE_KEY = + /(account|position|portfolio|holding|balance|equity|assets|netasset|net_asset|nav)/i; + +const NUMERIC_STRING = /^-?\d+(\.\d+)?$/; + +/** Numbers (or numeric-looking strings) are treated as account values. */ +export function isNumeric(value: unknown): boolean { + return ( + typeof value === 'number' || (typeof value === 'string' && NUMERIC_STRING.test(value)) + ); +} + +/** + * Default telemetry content policy (issue #19). `full` content tracing is an + * explicit opt-in; the default (`standard`) never uploads raw user content + * beyond redacted prompt/answer/tool metadata, and `minimal` ships names, + * statuses, durations and counts only. The long-form policy lives in + * docs/privacy-redaction.md. + */ +export const DEFAULT_TELEMETRY_CONTENT_POLICY = { + defaultLevel: 'standard' as const, + levels: ['minimal', 'standard', 'full'] as const, + fullContentRequiresOptIn: true, + alwaysRedacted: ['credentials', 'authorization headers', 'cookies', 'connection strings'], + neverCollected: ['portfolio holdings', 'account balances', 'private conversation storage'], +}; diff --git a/packages/shared/src/privacy/redact-text.test.ts b/packages/shared/src/privacy/redact-text.test.ts new file mode 100644 index 0000000..381a866 --- /dev/null +++ b/packages/shared/src/privacy/redact-text.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'bun:test'; +import { redactText } from './redact-text.ts'; + +describe('redactText — issue #19 secret classes', () => { + it('redacts provider API keys', () => { + expect(redactText('using key sk-abcdef1234567890 now')).not.toContain('abcdef1234567890'); + expect(redactText('key sk-ant-api03-abcdef1234567890abcdef')).not.toContain('api03-abcdef'); + expect(redactText('Google AIzaSyA1234567890abcdefghijklmnopqrstuv')).toContain('[REDACTED]'); expect(redactText('slack xoxb-123456789012-1234567890123-abc')).toContain('[REDACTED]'); + expect(redactText('sendgrid SG.abcdef1234567890.zyxwvutsrqponmlkji')).toContain('[REDACTED]'); + expect(redactText('gitlab glpat-abcdefghijklmnopqrst')).toContain('[REDACTED]'); + }); + + it('redacts Authorization headers (Bearer and Basic)', () => { + expect(redactText('Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456')).toBe( + 'Authorization: Bearer [REDACTED]' + ); + expect(redactText('authorization: Basic dXNlcjpwYXNzd29yZA==')).toContain('Basic [REDACTED]'); + }); + + it('redacts cookie and session headers', () => { + const out = redactText('cookie: session=abc123def456; theme=dark'); + expect(out).toContain('[REDACTED]'); + expect(out).not.toContain('abc123def456'); + expect(redactText('Set-Cookie: sid=deadbeefcafebabe')).not.toContain('deadbeefcafebabe'); + expect(redactText('"session_token": "xyz987654321"')).not.toContain('xyz987654321'); + }); + + it('redacts connection strings with credentials', () => { + const out = redactText('postgres://folio_user:hunter2@db.internal:5432/folio'); + expect(out).not.toContain('hunter2'); + expect(out).not.toContain('folio_user:'); + expect(out).toContain('postgres://[REDACTED]@db.internal:5432/folio'); + expect(redactText('redis://:secretpw@cache.internal:6379/0')).not.toContain('secretpw'); + expect(redactText('mongodb+srv://reader:pw123456@cluster0.example.net')).not.toContain('pw123456'); + }); + + it('redacts webhook secrets and signatures', () => { + expect(redactText('stripe whsec_abcdef1234567890abcdef')).not.toContain('abcdef1234567890'); + expect(redactText('x-hub-signature-256: sha256=abcdef1234567890abcdef1234567890abcdef12')).toContain( + '[REDACTED]' + ); + expect(redactText('"webhook_secret": "whsec_abcdefghijklm"')).not.toContain('whsec_abcdefghijkl'); + }); + + it('redacts secrets inside URL query strings', () => { + const out = redactText('https://api.example.com/v1/quotes?symbol=AAPL&apikey=abcdef1234567890'); + expect(out).toContain('symbol=AAPL'); + expect(out).not.toContain('abcdef1234567890'); + expect(redactText('https://host/pull?access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a')).not + .toContain('gho_16C7e42F292c6912E7710c838347'); + expect(redactText('https://host/cb#token=abcdefgh12345678&state=x')).not.toContain('abcdefgh12345678'); + // Benign query parameters survive. + expect(redactText('https://host/quotes?symbol=AAPL.US&range=1d')).toBe( + 'https://host/quotes?symbol=AAPL.US&range=1d' + ); + }); + + it('redacts private key PEM blocks', () => { + const pem = [ + '-----BEGIN RSA PRIVATE KEY-----', + 'MIIEpAIBAAKCAQEA7x9zLm/PlaceholderKeyMaterialForTesting1234567890', + '-----END RSA PRIVATE KEY-----', + ].join('\n'); + const out = redactText(`failed to load key:\n${pem}\nretry`); + expect(out).toContain('[REDACTED]'); + expect(out).not.toContain('MIIEpAIBAAKCAQEA'); + expect(out).toContain('failed to load key:'); + }); + + it('redacts secrets echoed inside HTTP error bodies', () => { + const body = 'Request failed with status 401: {"error":{"message":"Incorrect API key provided: sk-abcdef1234567890xyz"}}'; + const out = redactText(body); + expect(out).toContain('401'); + expect(out).not.toContain('sk-abcdef1234567890xyz'); + }); + + it('preserves observability fields (ids, names, statuses, versions)', () => { + expect(redactText('run_1a2b3c4d-5678-4ef0-ab12-cd34ef56ab78')).toBe( + 'run_1a2b3c4d-5678-4ef0-ab12-cd34ef56ab78' + ); + expect(redactText('1a2b3c4d-5678-4ef0-ab12-cd34ef56ab78')).toBe('1a2b3c4d-5678-4ef0-ab12-cd34ef56ab78'); + expect(redactText('market.quote')).toBe('market.quote'); + expect(redactText('completed')).toBe('completed'); + expect(redactText('claude-3-5-sonnet-20241022')).toBe('claude-3-5-sonnet-20241022'); + expect(redactText('AAPL.US')).toBe('AAPL.US'); + expect(redactText('latencyMs=1234, status=ok, toolName=get_quote')).toBe( + 'latencyMs=1234, status=ok, toolName=get_quote' + ); + // 40-char lowercase git SHA survives (not a secret). + expect(redactText('a'.repeat(40))).toBe('a'.repeat(40)); + }); + + it('is idempotent', () => { + const once = redactText( + 'Authorization: Bearer abcdefghijklmnop && postgres://u:pw@h/d and sk-abcdef1234567890' + ); + expect(redactText(once)).toBe(once); + }); + + it('keeps short lookalikes untouched', () => { + expect(redactText('lsv2_test')).toBe('lsv2_test'); + expect(redactText('password')).toBe('password'); + expect(redactText('0.17.0')).toBe('0.17.0'); + }); +}); diff --git a/packages/shared/src/privacy/redact-text.ts b/packages/shared/src/privacy/redact-text.ts new file mode 100644 index 0000000..7f73074 --- /dev/null +++ b/packages/shared/src/privacy/redact-text.ts @@ -0,0 +1,75 @@ +// String-level secret redaction — the single pattern source for every +// outbound-data boundary (issue #19). `redactText` strips credential-shaped +// material while preserving surrounding text, so traces keep run ids, tool +// names, latencies and statuses. It is fail-closed: if the pattern engine +// itself misbehaves, the whole string is replaced with REDACTED rather than +// falling back to the raw payload. +import { REDACTED } from './policy.ts'; + +type Replacement = string; + +/** + * Ordered rules applied left to right. Earlier matches win; later rules see + * text that already contains [REDACTED] markers, which no rule re-matches, + * keeping the engine idempotent. + */ +const PATTERNS: ReadonlyArray = [ + // PEM private key blocks — before the base64 rule so the armor survives. + [/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY(?: BLOCK)?-----/g, REDACTED], + // OpenAI/Anthropic-style keys: sk-…, sk-ant-…, rk-/pk-/ak-… (dash or not). + [/\b(?:sk-ant-|sk-|rk-|pk-|ak-)[A-Za-z0-9_-]{8,}\b/g, REDACTED], + // AWS access key ids (20 uppercase alphanumeric chars prefixed with AKIA). + [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED], + // Bearer / Basic auth headers: keep the scheme, redact the token. + [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/g, `$1${REDACTED}`], + [/\b(Basic\s+)[A-Za-z0-9._~+/=-]{8,}/g, `$1${REDACTED}`], + // Cookie / session headers (also JSON/`k=v` forms): the whole value is sensitive. + [/(\b(?:cookie|set-cookie|(?:x[-_])?session[_-]?token)["']?\s*[:=]\s*["']?)[^"'\r\n]{4,}/gi, `$1${REDACTED}`], + // Connection strings with userinfo credentials: postgres://user:pass@host, + // redis://:pass@host, mongodb+srv://… — keep the scheme and host, drop the creds. + [/\b((?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqps?|mssql|ftp|ftps|sftp|https?|wss?):\/\/)[^\s:@/"']*:[^\s@/"']+@/g, `$1${REDACTED}@`], + // Secret query parameters in URLs: ?apikey=…&token=…&signature=…. + [/([?&#](?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|session[_-]?(?:id|token)|auth|token|secret|password|passwd|signature|credentials?)=)[^&\s"'>]{4,}/gi, `$1${REDACTED}`], + // Webhook signatures & shared secrets. + [/\bwhsec_[A-Za-z0-9]{8,}\b/g, REDACTED], + [/((?:x-hub-signature(?:-256)?|x-webhook-signature|x-signature|x-signal-signature)["']?\s*[:=]\s*["']?)[A-Za-z0-9=+/_-]{8,}/gi, `$1${REDACTED}`], + // API-key headers / JSON fields: x-api-key, X-Api-Key, apiKey, api_key. + [/(["']?(?:x-api-key|X-Api-Key|api[_-]?key)["']?\s*[:=]\s*["']?)[A-Za-z0-9._~+/=-]{8,}["']?/g, `$1${REDACTED}`], + // AWS secret access keys in key/value context (40-char base64ish). + [/((?:aws_secret_access_key|secret_access_key|SecretAccessKey)["']?\s*[:=]\s*["']?)[A-Za-z0-9/+=]{35,}/g, `$1${REDACTED}`], + // JWTs (header.payload.signature). + [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, REDACTED], + // GitHub / common VCS tokens. + [/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9]{8,}\b/g, REDACTED], + [/\bglpat-[A-Za-z0-9_-]{20,}\b/g, REDACTED], + [/\bnpm_[A-Za-z0-9]{30,}\b/g, REDACTED], + // LangSmith API keys: lsv2_pt_/lsv2_sk_ prefixes, or a bare lsv2_ + 8+ chars. + // The suffix is hex, which the base64 pattern below deliberately skips, and + // contains underscores, which the api-key header pattern cannot span. + [/\blsv2_(?:pt_|sk_)?[A-Za-z0-9]{8,}\b/g, REDACTED], + // Slack tokens (xoxb-/xoxp-/…), SendGrid keys, Google API keys / OAuth. + [/\bxox[baprsce]-[A-Za-z0-9-]{10,}\b/g, REDACTED], + [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g, REDACTED], + [/\bAIza[0-9A-Za-z_-]{30,}\b/g, REDACTED], + [/\bya29\.[A-Za-z0-9_-]{20,}\b/g, REDACTED], + // Base64-ish blobs: long runs (≥40 chars) that include an uppercase char, + // which excludes lowercase git SHAs and hex identifiers. + [/\b(?=[A-Za-z0-9+/]{40,}={0,2})(?=[A-Za-z0-9+/]*[A-Z])[A-Za-z0-9+/]{40,}={0,2}/g, REDACTED], +]; + +/** + * Strip secret-shaped material from a string, preserving surrounding text. + * Fail-closed: on an internal error the entire string becomes REDACTED — + * redaction never falls back to emitting the raw payload. + */ +export function redactText(text: string): string { + try { + let out = text; + for (const [pattern, replacement] of PATTERNS) { + out = out.replace(pattern, replacement); + } + return out; + } catch { + return REDACTED; + } +}