@zxh126/pi-redact-secrets0.2.0
Redact secrets from pi tool output, user input and messages before they reach the model or the session file.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-redact-secrets
English · 简体中文
在内容进入模型上下文或写入会话文件之前把敏感串替换掉。
一个 pi 扩展,挂在文本进入对话的三条路径上:
| 钩子 | 覆盖范围 |
|---|---|
input |
你输入/粘贴的内容——包括从 QQ、Telegram、Slack 等桥接进来的消息 |
tool_result |
所有工具输出:bash、pwsh、read、grep、MCP、codemode 脚本 |
message_end |
模型自己的输出,避免它把密钥复述回对话里 |
因为脱敏发生在入口处,密钥不会进入模型上下文,也不会写进
~/.pi/agent/sessions/*.jsonl。
你 / 工具 ──▶ [ 脱敏 ] ──▶ 模型上下文 ──▶ 会话文件
│
└─ 规则(已知形状) + 字面量(你机器上的真实值)
安装
pi install npm:@zxh126/pi-redact-secrets
只想临时试用、不动配置:
pi -e npm:@zxh126/pi-redact-secrets
然后在 pi 里 /reload(或新开),查看状态:
/redact
配置
全部可选。没有配置文件时使用标准规则集、没有字面量。
/redact init # 生成 ~/.pi/agent/redact-secrets.json
{
"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 本身保持平台无关:
// 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 数组):
$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 支持的存储:
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做一次离线处理。
开发
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,可以单独使用:
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 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 会原样保留,不再阻断调用(失败会推迟到命令本身)。
后端
| 平台 | 后端 | 位置 |
|---|---|---|
| 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 一次。
配置新增两段
{
"store": { "dir": "~/.pi/agent/secrets/store", "backend": "auto" },
"injection": {
"envPrefix": "PI_SECRET_",
"exposeKeyNames": true,
"envRefSyntax": "pwsh",
"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