# pi-redact-secrets

[English](./README.md) · **简体中文**

在内容**进入模型上下文或写入会话文件之前**把敏感串替换掉。

一个 [pi](https://pi.dev) 扩展，挂在文本进入对话的三条路径上：

| 钩子 | 覆盖范围 |
|---|---|
| `input` | 你输入/粘贴的内容——包括从 QQ、Telegram、Slack 等桥接进来的消息 |
| `tool_result` | 所有工具输出：`bash`、`pwsh`、`read`、`grep`、MCP、codemode 脚本 |
| `message_end` | 模型自己的输出，避免它把密钥复述回对话里 |

因为脱敏发生在入口处，密钥不会进入模型上下文，也不会写进
`~/.pi/agent/sessions/*.jsonl`。

```
你 / 工具 ──▶ [ 脱敏 ] ──▶ 模型上下文 ──▶ 会话文件
                 │
                 └─ 规则（已知形状） + 字面量（你机器上的真实值）
```

## 安装

```bash
pi install npm:@zxh126/pi-redact-secrets
```

只想临时试用、不动配置：

```bash
pi -e npm:@zxh126/pi-redact-secrets
```

然后在 pi 里 `/reload`（或新开），查看状态：

```
/redact
```

## 配置

全部可选。没有配置文件时使用标准规则集、没有字面量。

```bash
/redact init       # 生成 ~/.pi/agent/redact-secrets.json
```

```jsonc
{
  "redactInput": true,
  "redactToolResult": true,
  "redactMessage": true,
  "profile": "standard",          // "standard" | "strict"
  "secrets": ["我的精确口令"],
  "secretFiles": ["~/.env", "~/.pi/agent/dbhub/mysql.toml"],
  "secretCommand": {
    "command": "pwsh",
    "args": ["-NoProfile", "-File", "~/.pi/agent/dump-secrets.ps1"],
    "timeoutMs": 8000
  },
  "minSecretLength": 8,
  "logFile": "~/.pi/agent/redact-secrets.log"
}
```

环境变量 `PI_REDACT_SECRETS_CONFIG` 可以指定别的配置路径。

| 键 | 默认 | 说明 |
|---|---|---|
| `redactInput` | `true` | 是否脱敏用户输入。若有时确实要把口令交给模型，可关掉 |
| `redactToolResult` | `true` | 是否脱敏工具输出 |
| `redactMessage` | `true` | 是否脱敏最终消息 |
| `profile` | `"standard"` | `strict` 额外启用高熵规则，能抓未知形状的随机串，但 base64 也会被误伤 |
| `secrets` | `[]` | 逐字替换的字面量 |
| `secretFiles` | `[]` | 从文件提取字面量：`.env`/TOML/YAML 里 key 名像密钥的 `KEY=value`、`scheme://user:pass@` 形式的 DSN、以及含密钥字段的 `.json` |
| `secretCommand` | — | 执行该程序，stdout 输出 JSON 字符串数组或每行一个值作为字面量；不经 shell，有超时 |
| `minSecretLength` | `8` | 太短的字面量会被忽略（否则会误伤普通文本） |
| `logFile` | `~/.pi/agent/redact-secrets.log` | 每次脱敏记一行：按规则的命中数，**不含值** |

### 为什么需要字面量

规则只能抓「有特征的」密钥（`sk-…`、`ghp_…`、PEM 块、DSN、`password=…`）。
像 `264613@Ms` 这种数据库口令在文本里看起来就是普通词，规则抓不到。
字面量补这个缺口：把 `secretFiles` 指向你机器上已有的配置文件，或用
`secretCommand` 在会话启动时现算。

扩展本体**不带任何加密实现**：它只在会话启动时读一次明文并保存在内存里。加密交给你
本来就信任的那个密钥库，按平台来：

| 平台 | 密钥库 | 对应命令 |
|---|---|---|
| Windows | DPAPI（`CryptProtectData`） | `pwsh -File dump-secrets.ps1` |
| macOS | 钥匙串 | `security find-generic-password -s pi-secrets -w` |
| Linux（桌面） | Secret Service（GNOME Keyring / KWallet） | `secret-tool lookup service pi-secrets` |
| Linux（服务器） | `0600` 文件，或 systemd ≥ 250 上的 `systemd-creds` | `cat ~/.pi-secrets` / `systemd-creds decrypt x.cred -` |

选哪个之前得知道两条限制，因为 `secretCommand` 是**没有 TTY** 的后台执行：

- **必须能无提示解锁**。DPAPI、钥匙串、Secret Service 都满足：系统在登录时就已经拿到了密钥。
  `gpg`、`age`、`pass` **不**满足：它们通过 pinentry 要口令，后台命令直接失败
  （除非 agent 已解锁）。真想用它们就写 `--pinentry-mode loopback --passphrase-file`，
  或者包一层从密钥环取口令的脚本。
- **耐久性**。用户级 DPAPI 密文绑在用户配置上。你**自己改密码**时 Windows 会重新包装主密钥，
  但**管理员重置密码**（或重建用户配置）后主密钥就解不开了——密钥数据直接报废，
  Windows 凭据管理器里的东西（git 凭据、RDP 密码）一起没。要么做一张密码重设盘，
  要么把长期密钥放到不依赖账户的地方（密码管理器、硬件令牌、带 TPM 的 `systemd-creds`）。
  这其实是所有「绑定登录态」的密钥库共有的问题，钥匙串和 Secret Service 也一样——
  只是 DPAPI 这条翻车声音最大。

每个都只是一个打印值的可执行文件，所以 `secretCommand` 本身保持平台无关：

```jsonc
// Windows：DPAPI blob，由 PowerShell 解密
{ "secretCommand": { "command": "pwsh", "args": ["-NoProfile", "-File", "~/dump-secrets.ps1"] } }

// macOS：钥匙串里一条，每行一个值
{ "secretCommand": { "command": "security", "args": ["find-generic-password", "-s", "pi-secrets", "-w"] } }

// Linux：Secret Service
{ "secretCommand": { "command": "secret-tool", "args": ["lookup", "service", "pi-secrets"] } }

// Linux 没有密钥环：一个只有你账户能读的文件
{ "secretCommand": { "command": "cat", "args": ["/home/me/.pi-secrets"] } }
```

命令可以输出 **JSON 字符串数组**，也可以输出**纯文本、每行一个**（`secret-tool`、
`security`、`pass`、`gpg` 都是这种形式）。需要管道就用显式 shell，因为
`secretCommand` 从不经 shell：`{ "command": "sh", "args": ["-c", "pass show pi/secrets"] }`。

Windows DPAPI 示例（`dump-secrets.ps1`，输出 JSON 数组）：

```powershell
$list = [System.Collections.Generic.List[string]]::new()
function AddV($v) {
  if (-not $v) { return }
  $s = [string]$v
  if ($s.Length -ge 8 -and $s -notmatch '^\d+$') { if (-not $list.Contains($s)) { $list.Add($s) } }
}
# …在这里读你自己的配置文件或解密 DPAPI blob…
AddV (ConvertTo-SecureString (Get-Content "$HOME\.secret.dpapi" -Raw) |
  ForEach-Object { [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
    [Runtime.InteropServices.Marshal]::SecureStringToBSTR($_)) })
ConvertTo-Json -InputObject @($list) -Compress
```

Linux 上无密钥环时的等价做法是一个 `chmod 600` 文件；要加密又不能提示，
就用 `systemd-creds`（systemd ≥ 250）或 TPM 支持的存储：

```bash
printf '%s\n' "$DB_PASSWORD" "$SMTP_CODE" > ~/.pi-secrets
chmod 600 ~/.pi-secrets
# secretCommand = { command = "cat", args = ["/home/me/.pi-secrets"] }
```

`secretFiles` 是零配置选项：直接用你已经有的 `chmod 600` 文件（`.env`、类 `~/.pgpass`
的配置、`dbhub/mysql.toml`）。它不提供加密，只有文件系统权限——单用户笔记本尚可，
共享主机不够。

## 规则

| 规则 | 输入示例 | 输出 |
|---|---|---|
| `pem` | `-----BEGIN OPENSSH PRIVATE KEY-----…` | `<REDACTED-PEM>` |
| `auth-header` | `Authorization: Bearer abcdef123456` | `Authorization: Bearer <REDACTED>` |
| `openai-key` | `sk-proj-abcdefghijklmnop` | `sk-<REDACTED>` |
| `github-token` | `ghp_abcdefghijklmnopqrst` | `<REDACTED-GH>` |
| `npm-token` | `npm_abcdefghijklmnopqrstuvwxyz` | `npm_<REDACTED>` |
| `aws-access-key` | `AKIAIOSFODNN7EXAMPLE` | `AKIA<REDACTED>` |
| `jwt` | `eyJhbGciOi….eyJzdWIi….s3cr3t` | `<REDACTED-JWT>` |
| `dsn` | `mysql://root:pa55word@host/db` | `mysql://root:***@host/db` |
| `url-userinfo` | `https://user:pass@host` | `https://***:***@host` |
| `json-secret` | `{"appSecret": "eFh2DD4o…"}` | `{"appSecret": "<REDACTED>"}` |
| `key-value` | `SMTP_PASS=…`、`db_password: …`、`密码：…` | `SMTP_PASS=<REDACTED>` |
| `high-entropy`（strict） | 长随机串 | `<REDACTED-HIGH-ENTROPY>` |

规则是**幂等**的：已脱敏的值（`***`、`<REDACTED>`）不会被二次匹配，恢复会话时
不会出现占位符套占位符。

## 命令

| 命令 | 作用 |
|---|---|
| `/redact` | 规则数、字面量数量与来源、开关、配置与日志路径 |
| `/redact reload` | 重新读取配置并重跑 `secretCommand` |
| `/redact init` | 写一份示例配置（不会覆盖已有文件） |

## 跨平台

纯 Node 实现：没有原生模块、没有 shell 调用、没有平台分支。

| 平台 | 状态 |
|---|---|
| Windows 10/11 | 已验证：Node 24、PowerShell 7、pi 1.4 |
| Linux (x64) | 已验证：Ubuntu 20.04 + Node 22.23.4（28 个测试，含钩子接线测试） |
| macOS / arm64 | 预期可用——与 Linux 同一套代码路径 |

- **需要 Node ≥ 22.18**：包直接发布 TypeScript 源码，依赖 Node 原生类型剥离（pi 本身也要求 Node 22+）。
- 路径一律走 `path.join` / `os.homedir()`，配置值里的 `~`、`~/` 会被展开；代码里没有任何盘符或 Windows 专有 API。
- `secretCommand` 通过 `execFile` 执行，**不经 shell**，命令与参数由你按平台填写（Windows 用 `pwsh`，Linux 用 `sh`/`node`/`python3`），没有任何写死的可执行文件。
- 日志文件在 POSIX 上以 `0600` 权限创建（Windows 忽略该模式）。

## 它不做什么

- **不脱敏工具参数**。改写 `bash`/`pwsh` 的参数等于改变实际执行的命令，所以
  `pwsh -Command "login --password hunter2"` 里的口令仍会留在工具调用里。
  请改用环境变量或文件传参。
- **不动 `tool_result.details`**，TUI 渲染依赖它。
- 脱敏是「尽力而为 + 基于模式」的：它降低暴露面，不是保证。最安全的做法仍然是
  让敏感数据不离开本机。
- 已经写进会话文件的历史不会自动清理。需要的话对
  `~/.pi/agent/sessions/**/*.jsonl` 做一次离线处理。

## 开发

```bash
npm test          # node --test，无需构建（Node ≥ 22.18 原生剥离类型）
npm run check     # tsc --noEmit
pi -e .           # 从本目录加载包
```

CI 在 `ubuntu-latest` 上用 Node 22 和 24 跑 `npm test` 与 `npm run check`。
`test/extension.test.ts` 用一个假的 `ExtensionAPI` 驱动扩展，不用启动 pi 就能覆盖三个钩子、
配置读取、字面量收集和日志文件。

结构：

```
src/index.ts     pi 接线：三个钩子 + /redact 命令
src/rules.ts     规则与替换模板
src/redactor.ts  纯脱敏引擎（有单测、无 I/O）
src/literals.ts  字面量收集（配置、文件、命令）
src/config.ts    配置结构、默认值与校验
```

引擎不依赖 pi，可以单独使用：

```ts
import { Redactor } from "@zxh126/pi-redact-secrets/src/redactor.ts";

const redactor = new Redactor({ profile: "strict" });
redactor.setSecrets(["我的精确口令"]);
redactor.redact("password=我的精确口令");
// { text: "password=<REDACTED>", hits: 1, byRule: {…} }
```

## 存储与引用（0.2.0 起）

0.2.0 把单独的 `pi-secret-ref` 并入本包：**存值、注入、引用、脱敏共用同一份内存里的值**。不再需要装两个扩展，也不再有加载顺序问题。

### 命令

| 命令 | 作用 |
|---|---|
| `/secret db=<值>` | 存一个值。打字时可见，提交后转录里只有 `db=******` |
| `/secret add db <值>` | 同上，动词形式。`add` / `set` / `put` 互为别名；值里带 `=` `?` `&`（签名 URL）时更好写 |
| `/secret db` | 同上，值从输入框取——不进编辑器、不进 ↑ 历史，推荐 |
| `/secret` | 列出 key 名、注入的环境变量名、后端（永不显示值） |
| `/secret test db` | 验证能解密并已注入，只报长度 |
| `/secret rm db` | 删除，并还原该环境变量 |
| `/secret import db --from <来源>` | `env:NAME` / `dpapi:<path>` / `<文件>` / `command` |
| `/secret reload` | 重读配置、字面量来源和密钥库 |
| `/redact` | 规则数、字面量数、存储的 key 数、后端、加载时间 |
| `/redact reload` / `/redact init` | 重读全部 / 写示例配置 |

### 引用：四种写法，等价

```
[$secret.db]   [$secret:db]   <secret.db>   <secret:db>
```

key 名大小写不敏感。两种展开策略：

- **A 环境变量（默认）**：写在你的消息里，展开成 `$env:PI_SECRET_DB`（bash 下 `$PI_SECRET_DB`）。模型只看到变量名，值在子进程里展开。
- **B 执行期替换（opt-in，白名单）**：写在 `pwsh` / `bash` 的工具参数里，执行前换成真值，transcript 留下占位符。要把值写进文件就显式把 `write` / `edit` 加进白名单——那已经超出"不进上下文"的范围。

未知 key 两条路都**失败关闭**：A 原样保留并提示；B 直接 block 工具调用，绝不当空串放过去。

> 注意：因为 B 对白名单工具做整串扫描，任何**文档、代码、脚本里**出现引用写法（哪怕是举例）都会被当成引用处理；key 不存在就直接 block。写文档时用 `secret.key` 之类的变体描述，或者把示例放在不经过 shell 的工具里。确实需要放宽时设 `injection.substitution.blockOnUnknown: false`：未知 key 会原样保留，不再阻断调用（失败会推迟到命令本身）。

两端对未知 key 都是失败关闭：A 原样保留并告警（没有 UI 的渠道，比如 QQ 机器人，会把告警作为一段附注加到消息里，让模型知道这个引用被忽略了），B 直接阻断调用，而不是拿空密码去认证。

### 可逆的输入脱敏（`injection.vaultInput`）

以前对自己的消息脱敏是单向的：`https://…?token=abc123&id=42` 到模型这里就变成 `token=<REDACTED>`，想让你帮忙打开这个链接的任务直接做不了。

`injection.vaultInput`（默认 `true`）把命中的值**只存在内存里**（不落盘、不进 store），同时把消息改写成引用：

```
see https://ex.example.com/page?token=[$secret:auto1]&id=42 please

> pi-redact-secrets: 1 secret value in your message was replaced by a reference …: [$secret:auto1] -> PI_SECRET_AUTO1. …
```

模型拿到的是能用的引用而不是死掉的 `<REDACTED>`；transcript 和 session 文件里没有任何值；shell 工具能用 `PI_SECRET_AUTO1`。如果这个值本来就是某个 `/secret` key 的值，直接复用那个 key，不会再造一个 `autoN`；`autoN` 也绝不会覆盖已存储的 key。设 `vaultInput: false` 回到旧的单向脱敏。

### 后端

| 平台 | 后端 | 位置 |
|---|---|---|
| Windows | DPAPI（当前用户） | `store/<key>.dpapi` |
| macOS | Keychain（service `pi-redact-secrets`） | 系统钥匙串 |
| Linux | Secret Service（`secret-tool`） | 系统密钥环 |
| 兜底 | 仅文件权限，**不加密** | `store/<key>.secret`（0600） |

`store/index.json` 只记 `{backend, updated, length}`；值只在后端里。启动时一次 pwsh 批量解密所有 DPAPI blob，不是每个 key 一次。

### 配置新增两段

```jsonc
{
  "store": { "dir": "~/.pi/agent/secrets/store", "backend": "auto" },
  "injection": {
    "envPrefix": "PI_SECRET_",
    "exposeKeyNames": true,
    "envRefSyntax": "pwsh",
    "vaultInput": true,
    "substitution": { "enabled": true, "tools": ["pwsh", "bash"], "blockOnUnknown": true }
  }
}
```

`exposeKeyNames` 把 key 名（不含值）写进 system prompt 的 `secrets` 段。`store.backend` 可强制 `file`（CI / 无密钥环时最省事）。老的配置键一个没变，不写这两段就是默认行为。

### 边界

- 模型知道变量名，就能自己 `echo $env:PI_SECRET_DB`。工具输出里的已知值会被脱敏，但脱敏是模式匹配——base64、取子串这类变体会漏。
- 环境变量对子进程透明：MCP server、嵌套 pi、PTY 都能读到。高价值凭据应该用短期凭据。
- `/secret db=值` 会进编辑器的内存历史（不落盘，pi 退出即消失）。用 `/secret db` 可避开。
- DPAPI 密文绑定用户配置：管理员重置密码或重建 profile 后作废。
- 已经写进 `sessions/*.jsonl` 的历史不会回溯清理。

## 许可

MIT


