Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions apps/electron/src/main/credentialStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,15 +30,14 @@ interface StoreShape {
customProviders: Record<string, CustomProviderRecord>;
}

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 {
Expand Down
10 changes: 8 additions & 2 deletions apps/electron/src/main/kernelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions docs/privacy-redaction.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions docs/privacy-redaction.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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` 额外剔除账户类数值字段;正文与证据原样通过。
- **凭证存储** —— 所有错误路径经统一引擎脱敏;存储本身只向渲染进程返回元数据。
3 changes: 2 additions & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/diagnostics/error-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
});
});
9 changes: 7 additions & 2 deletions packages/shared/src/diagnostics/error-log.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
55 changes: 9 additions & 46 deletions packages/shared/src/diagnostics/redact.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [RegExp, Replacement]> = [
// 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';
6 changes: 5 additions & 1 deletion packages/shared/src/evaluation/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
EvaluationSettings,
ToolCallRecord,
} from '@finagent/core';
import { redactError } from '../privacy/deep-redact.ts';

export interface EvaluationContext {
case: EvaluationCase;
Expand Down Expand Up @@ -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,
});
}
}
Expand Down
36 changes: 2 additions & 34 deletions packages/shared/src/evaluation/redactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, true> = {
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
Expand Down Expand Up @@ -65,14 +41,6 @@ export interface RedactionResult<T> {
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;

Expand Down
Loading
Loading