pi-information-protecter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/SECURITY.md +30 -0
- package/package.json +28 -0
- package/protecter.example.json +11 -0
- package/src/config.ts +120 -0
- package/src/engine.ts +114 -0
- package/src/guard.ts +65 -0
- package/src/index.ts +146 -0
- package/src/scan-worker.mjs +150 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-18
|
|
4
|
+
|
|
5
|
+
首个公开版本:**个人敏感信息保护插件 — Sensitive Personal Information (SPI) Protecter**。
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- Pi 最终 provider 请求体的本地脱敏,覆盖系统提示、历史、工具结果及工具定义。
|
|
10
|
+
- `protecter.json` 敏感词数组、字面匹配及 JavaScript 正则表达式。
|
|
11
|
+
- 随机 192-bit 占位符、仅内存映射、最终回复及工具参数本地还原。
|
|
12
|
+
- TUI Markdown 流式展示还原;已学习敏感值在当前实例内继续按精确值保护。
|
|
13
|
+
- 配置文件 0600 权限及直接访问防护,配置异常与扫描超时的安全替代请求。
|
|
14
|
+
- Worker 正则扫描与资源限制;`/protecter status` 和 `/protecter reload`。
|
|
15
|
+
- 配置示例、安全边界说明及自动化测试,包括真实 Pi loader/runner 集成测试。
|
|
16
|
+
|
|
17
|
+
### 兼容性与已知限制
|
|
18
|
+
|
|
19
|
+
- Node.js 22+,Pi `@earendil-works/pi-coding-agent` 0.85.1–0.85.x;验证基线 0.85.1。
|
|
20
|
+
- 初始规则为空,需用户本地配置;不会自动识别所有 SPI。
|
|
21
|
+
- 暂不支持上下文压缩、带摘要的树导航及常见多模态附件。
|
|
22
|
+
- RPC/JSON 流式 delta 保留占位符,最终消息还原;映射不会跨进程保存。
|
|
23
|
+
- 不是沙箱;不防任意 shell 编码绕过,不覆盖独立网络调用、HTTP 认证 headers、本地会话及导出。详见 SECURITY.md。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-information-protecter contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# pi-information-protecter
|
|
2
|
+
|
|
3
|
+
**个人敏感信息保护插件 — Sensitive Personal Information (SPI) Protecter。**
|
|
4
|
+
|
|
5
|
+
这是一个 Pi 扩展:在本地审查即将发往 LLM 网关的请求,用随机占位符替换配置中的个人敏感信息;模型返回占位符后,在本地还原为原始内容。可用于 URL、电话号码、身份证/ID、姓名、凭证、密码等。**它是规则驱动的意外泄漏防护,不是安全沙箱、自动 PII 识别器或加密代理。**
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
要求 Node.js 22+、`@earendil-works/pi-coding-agent` **0.85.1–0.85.x**(验证基线 0.85.1)。旧 `@mariozechner/*` 版本不在兼容范围内。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 安装首个版本(固定版本便于复现)
|
|
13
|
+
pi install npm:pi-information-protecter@0.1.0
|
|
14
|
+
|
|
15
|
+
# 或在源码仓库内本地安装;本地路径安装不会复制仓库
|
|
16
|
+
pi install "$PWD"
|
|
17
|
+
# 或单次加载
|
|
18
|
+
pi -e ./src/index.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
已运行的 Pi 请执行 `/reload`。插件注册了 worker,因此分发时需要保留完整 `src/` 目录,不能只复制 `index.ts`。
|
|
22
|
+
|
|
23
|
+
版本变更见 [CHANGELOG.md](CHANGELOG.md)。源码贡献与发布流程见仓库中的 `CONTRIBUTING.md` 和 `docs/RELEASING.md`。
|
|
24
|
+
|
|
25
|
+
首次会话启动时创建 `~/.pi/agent/protecter.json`,遵循 Pi 的 `PI_CODING_AGENT_DIR` 覆盖设置。不会改写已有配置,不会读取项目级同名配置。Unix 文件权限收紧为 `0600`;符号链接、硬链接、多于 1 MiB 的配置和非当前用户所有的文件会被拒绝。Windows 还需用户自行配置 ACL。
|
|
26
|
+
|
|
27
|
+
## 配置敏感信息
|
|
28
|
+
|
|
29
|
+
**请用本地编辑器编辑配置,不要让模型帮你填写真实密码,也不要把真实配置提交到 Git。** 初始数组为空,此时没有个人信息检测规则,界面会警告;插件不声称自动识别全部敏感信息。
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"version": 1,
|
|
34
|
+
"sensitiveWords": [
|
|
35
|
+
"张三",
|
|
36
|
+
{ "type": "literal", "value": "your-private-password" },
|
|
37
|
+
{ "type": "regex", "pattern": "https?://[^\\s\\\"<>]+", "flags": "i" },
|
|
38
|
+
{ "type": "regex", "pattern": "(?<!\\d)1[3-9]\\d{9}(?!\\d)" }
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
更多示例见 [`protecter.example.json`](protecter.example.json),这些仅为示例规则,不会自动启用。号码示例不做真实性/校验位检查,姓名和密码通常应使用精确值规则。
|
|
44
|
+
|
|
45
|
+
- 字符串及 `literal`:大小写敏感、全量字面匹配(不是正则)。
|
|
46
|
+
- `regex`:JavaScript 正则,替换**整个匹配**;需要只匹配凭证值时可用 lookbehind。支持 `g i m s u`,自动补 `g`。不支持 `/pattern/flags` 简写。
|
|
47
|
+
- 正则中的反斜杠要按 JSON 规则写为 `\\`;不能匹配空字符串。运行中遇到零宽匹配会拒绝该请求。
|
|
48
|
+
- 匹配重叠时合并区间,避免只遮住短词而暴露长词后缀;同一原文在当前扩展实例中复用同一个占位符。
|
|
49
|
+
- 已匹配过的原文会在当前实例内继续按精确值保护,即使正则前缀消失或规则被删除。这样可以防止本地还原后的内容在后续请求中漏出;退出/重载后清除这些内存记录。
|
|
50
|
+
- 每次请求重新读取、校验配置。`/protecter`、`/protecter status` 或 `/protecter reload` 只报告规则/内存映射数量,不打印敏感值。没有把敏感值通过命令参数添加的功能。
|
|
51
|
+
- 配置最多 1000 条规则,每个词/模式最多 8192 字符;扫描时间上限 2 秒,请求 JSON 上限 8 MiB。超限拒绝,不降级为明文。
|
|
52
|
+
|
|
53
|
+
## 工作方式
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
用户输入/历史/工具结果/系统提示/工具定义
|
|
57
|
+
↓
|
|
58
|
+
Pi before_provider_request:最终 JSON payload 文本扫描
|
|
59
|
+
↓
|
|
60
|
+
__PIP_<192-bit random hex>__ → LLM 网关
|
|
61
|
+
↓
|
|
62
|
+
模型回复 → 本地 message_end 还原 + tool_call 参数还原
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
1. 扫描请求 JSON 的字符串、键和数字;也会解析 JSON 字符串形式的工具参数,避免转义隐藏匹配。不会修改本地用户输入或历史原文。
|
|
66
|
+
2. 使用 `crypto.randomBytes(24)` 生成占位符。映射**只保存在内存**,不写配置、会话自定义条目、日志或模型提示。用户仍可在本地看到原文。
|
|
67
|
+
3. 最终 assistant 文本、thinking 和工具参数还原;TUI 的 Markdown transformer 在完整占位符到达时还原流式显示,尚未到齐时可能暂时显示部分占位符。RPC/JSON 的流式 delta 仍为占位符,最终 `message_end` 还原。占位符被模型改写或截断时无法还原。
|
|
68
|
+
4. 工具执行前还原参数,让合法的本地文件操作能够使用原始值;下一次请求再次脱敏。**恢复真实凭证并不是工具授权或防外传机制。**
|
|
69
|
+
5. 正则匹配在可终止 worker 中运行,避免恶意/低效正则阻塞主线程。扫描失败时请求替换为 `{}` 并请求终止,不依赖会被 Pi 吞掉的 hook 异常。可能仍产生空请求或网关校验错误,但不返回原始 payload。
|
|
70
|
+
|
|
71
|
+
### 配置文件访问保护
|
|
72
|
+
|
|
73
|
+
以**统一出口脱敏为主,配置直接访问拦截为辅**。不全面禁用 shell:
|
|
74
|
+
|
|
75
|
+
- 在 `tool_call` 拦截明确涉及 `protecter.json` 的输入,包括常见 `read`、`write`、`edit` 和 `bash cat …`。
|
|
76
|
+
- 路径参数解析 `@`、`~`、相对路径、`file://`,检查符号链接及 inode 别名。阻止 `grep/find/ls` 扫描包含配置的祖先目录。
|
|
77
|
+
- 如果完整原始配置意外出现在请求文本中,也尝试整体替换。
|
|
78
|
+
- **这只是尽力保护**:动态 shell、base64、分段输出、目录内部 symlink、任意第三方工具、网络发送均可绕过路径检查。不能保证恶意模型永远无法获取配置。不要把任意 shell 的能力与强隔离同时视为已实现。
|
|
79
|
+
|
|
80
|
+
## 当前限制(请先读)
|
|
81
|
+
|
|
82
|
+
- 只保护命中规则的文本;不识别同义改写、字符拆分、任意编码。附件中无法可靠检查 SPI,因此检测到常见图片/音频/视频/文件块时拒绝整个请求;不是 OCR。
|
|
83
|
+
- 为避免摘要调用的不同生命周期泄漏/残留无法恢复的占位符,暂时取消 `/compact`、自动压缩和带摘要的 `/tree`。不带摘要的树导航可用;长会话请 `/new`。
|
|
84
|
+
- 映射在退出、`/reload`、会话切换时丢弃。已完成并还原的本地消息不依赖映射;崩溃前的残留占位符、旧摘要、原始 delta 无法跨重启恢复。
|
|
85
|
+
- 规则过宽(例如 `.`、全部数字或常见协议词)可能改写请求协议字段、模型名、工具 schema 或 ID,导致请求失败。数值命中会转为占位符字符串。优先使用具体规则。
|
|
86
|
+
- 同一占位符会暴露“值相同”的关系;文本上下文也可能推断身份。这不是形式化匿名化。
|
|
87
|
+
- 请求 hook 不覆盖 HTTP headers、认证 API key、网关地址、Pi 遥测、`/share`、其他扩展自行调用 `fetch`/模型 SDK 或工具网络访问。向模型提供服务的网关仍需要收到用于认证的网关凭证。
|
|
88
|
+
- **请将本插件排在其他 payload 修改扩展之后,并且只运行受信任的扩展。** 后续处理器仍可重新加入原文;其他扩展拥有本机权限,插件无法约束它们。插件加载失败时 Pi 也可能继续运行,应检查状态再输入敏感信息。
|
|
89
|
+
- 本地会话、终端、导出和工具写出的文件可能包含原文;本项目不加密或清理它们。
|
|
90
|
+
|
|
91
|
+
详细威胁模型见 [SECURITY.md](SECURITY.md)。
|
|
92
|
+
|
|
93
|
+
## 开发与验证
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm ci
|
|
97
|
+
npm run check
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
测试使用临时目录和虚构敏感值,不读取用户真实配置,不使用真实 API key 或访问 LLM 网关。包含脱敏/还原、正则超时、配置权限、路径防护及真实 Pi 扩展加载器/事件 runner 集成测试。
|
|
101
|
+
|
|
102
|
+
接口依据:[Pi Extensions](https://pi.dev/docs/latest/extensions)、本机 0.85.1 扩展文档及 runner 实现。项目遵循 MIT License。
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPI Protecter 安全边界
|
|
2
|
+
|
|
3
|
+
## 目标
|
|
4
|
+
|
|
5
|
+
防止正常使用 Pi 时,已配置的个人敏感信息以原始文本意外进入 LLM 请求体。威胁模型假设 Pi 核心、provider 实现、操作系统及其他已加载扩展可信,且该扩展成功加载、最终 hook 确实被调用。
|
|
6
|
+
|
|
7
|
+
## 非目标
|
|
8
|
+
|
|
9
|
+
- 不是操作系统沙箱,不防御同 UID 程序、恶意扩展、任意代码执行、调试器或内存抓取。
|
|
10
|
+
- 不防御对文本任意编码、拆分、翻译、截图/OCR后的重构;不提供自动隐私分类或形式化匿名化。
|
|
11
|
+
- 不保护网关认证 header、网关 endpoint、工具请求、独立 SDK 请求、遥测、会话导出或 `/share`。
|
|
12
|
+
- 不加密本地配置/会话/终端,也不在秘密被模型推断出来后自动阻止它外传。
|
|
13
|
+
|
|
14
|
+
## 实现策略
|
|
15
|
+
|
|
16
|
+
- 全 payload 文本出口扫描;随机 192-bit 占位符,只在本地内存保存反向映射。
|
|
17
|
+
- 正则 worker 2 秒超时、资源及输入限制。异常不包含原始配置、规则或请求文本。
|
|
18
|
+
- Pi 0.85.1 runner 会捕获 before_provider_request 异常并继续发送当前 payload,因此错误路径必须返回安全替代 `{}`,并尽力 abort。不能宣传为“一定没有 HTTP 请求”,只能保证该处理器不回退原始 payload。
|
|
19
|
+
- 配置单独创建,Unix 0600,读取时拒绝链接/非普通文件/多链接/其他用户所有文件。目录和操作系统权限仍需用户维护;路径检查存在 TOCTOU,不能抵御恶意本地并发修改。
|
|
20
|
+
- 直接文件访问拦截只是纵深防护。`bash` 可以绕过字符串检查;配置目录祖先保护会有误拦截。
|
|
21
|
+
- 配置整体文本检测只是补充,不保证各种重新格式化后的规则定义都被掩盖。
|
|
22
|
+
- 辅助压缩和树摘要保守禁用。未经验证的新 Pi 版本不应直接扩大兼容范围。
|
|
23
|
+
|
|
24
|
+
## 安全部署建议
|
|
25
|
+
|
|
26
|
+
1. 本地填写具体规则,确认状态与规则数量;空配置并不具备个人信息检测能力。
|
|
27
|
+
2. 使用虚构样例在自己信任的测试网关抓取请求验证,绝不要用真实秘密测试。
|
|
28
|
+
3. 避免同时使用上传日志/导出会话/自动遥测类扩展;确认 payload 处理顺序。
|
|
29
|
+
4. 高风险环境用容器/独立用户/独立文件系统,把配置放在执行工具不可访问的位置;同时限制网络出口。单靠本扩展不能提供这层保证。
|
|
30
|
+
5. 不向公共 issue 提交真实配置、映射、会话或含个人信息的抓包数据。
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-information-protecter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sensitive Personal Information (SPI) Protecter — local reversible privacy redaction for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/taills/pi-information-protecter.git" },
|
|
8
|
+
"homepage": "https://github.com/taills/pi-information-protecter#readme",
|
|
9
|
+
"bugs": { "url": "https://github.com/taills/pi-information-protecter/issues" },
|
|
10
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
11
|
+
"keywords": ["pi-package", "pi-extension", "privacy", "redaction"],
|
|
12
|
+
"files": ["src", "README.md", "SECURITY.md", "CHANGELOG.md", "protecter.example.json", "LICENSE"],
|
|
13
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
14
|
+
"engines": { "node": ">=22" },
|
|
15
|
+
"peerDependencies": { "@earendil-works/pi-coding-agent": ">=0.85.1 <0.86.0" },
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"tsx": "^4.20.0",
|
|
20
|
+
"typescript": "^5.9.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "tsx --test test/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"check": "npm run typecheck && npm test",
|
|
26
|
+
"prepublishOnly": "npm run check"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"sensitiveWords": [
|
|
4
|
+
"张三",
|
|
5
|
+
{ "type": "literal", "value": "replace-with-your-real-password" },
|
|
6
|
+
{ "type": "regex", "pattern": "https?://[^\\s\\\"<>]+", "flags": "i" },
|
|
7
|
+
{ "type": "regex", "pattern": "(?<!\\d)1[3-9]\\d{9}(?!\\d)" },
|
|
8
|
+
{ "type": "regex", "pattern": "(?<![0-9A-Za-z])\\d{17}[0-9Xx](?![0-9A-Za-z])" },
|
|
9
|
+
{ "type": "regex", "pattern": "(?<=Bearer )[A-Za-z0-9._~+/-]+=*", "flags": "i" }
|
|
10
|
+
]
|
|
11
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
closeSync,
|
|
4
|
+
fchmodSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
export type Rule =
|
|
14
|
+
| string
|
|
15
|
+
| { type: "literal"; value: string }
|
|
16
|
+
| { type: "regex"; pattern: string; flags?: string };
|
|
17
|
+
export interface Config {
|
|
18
|
+
version: 1;
|
|
19
|
+
sensitiveWords: Rule[];
|
|
20
|
+
}
|
|
21
|
+
export const DEFAULT_CONFIG: Config = { version: 1, sensitiveWords: [] };
|
|
22
|
+
export const CONFIG_ERROR =
|
|
23
|
+
"protecter: 配置无法安全读取或格式无效;请在本地修复 protecter.json。";
|
|
24
|
+
|
|
25
|
+
export function parseConfig(raw: string): Config {
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(raw);
|
|
28
|
+
if (
|
|
29
|
+
!value ||
|
|
30
|
+
value.version !== 1 ||
|
|
31
|
+
!Array.isArray(value.sensitiveWords) ||
|
|
32
|
+
value.sensitiveWords.length > 1000 ||
|
|
33
|
+
Object.keys(value).some((k) => !["version", "sensitiveWords"].includes(k))
|
|
34
|
+
)
|
|
35
|
+
throw new Error();
|
|
36
|
+
for (const rule of value.sensitiveWords) {
|
|
37
|
+
if (typeof rule === "string") {
|
|
38
|
+
if (!rule || rule.length > 8192) throw new Error();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!rule || typeof rule !== "object") throw new Error();
|
|
42
|
+
if (rule.type === "literal") {
|
|
43
|
+
if (
|
|
44
|
+
Object.keys(rule).some((k) => !["type", "value"].includes(k)) ||
|
|
45
|
+
typeof rule.value !== "string" ||
|
|
46
|
+
!rule.value ||
|
|
47
|
+
rule.value.length > 8192
|
|
48
|
+
)
|
|
49
|
+
throw new Error();
|
|
50
|
+
} else if (rule.type === "regex") {
|
|
51
|
+
if (
|
|
52
|
+
Object.keys(rule).some(
|
|
53
|
+
(k) => !["type", "pattern", "flags"].includes(k),
|
|
54
|
+
) ||
|
|
55
|
+
typeof rule.pattern !== "string" ||
|
|
56
|
+
!rule.pattern ||
|
|
57
|
+
rule.pattern.length > 8192 ||
|
|
58
|
+
(rule.flags !== undefined &&
|
|
59
|
+
(typeof rule.flags !== "string" || !/^[gimsu]*$/.test(rule.flags)))
|
|
60
|
+
)
|
|
61
|
+
throw new Error();
|
|
62
|
+
const re = new RegExp(rule.pattern, rule.flags ?? "");
|
|
63
|
+
if (re.test("")) throw new Error();
|
|
64
|
+
} else throw new Error();
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(CONFIG_ERROR);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Do not follow a symlink, accept a hard-linked file, or print parser/OS errors containing secrets. */
|
|
73
|
+
export function loadConfig(
|
|
74
|
+
path: string,
|
|
75
|
+
create = false,
|
|
76
|
+
): { config: Config; raw: string } {
|
|
77
|
+
let fd: number | undefined;
|
|
78
|
+
try {
|
|
79
|
+
if (create) {
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
81
|
+
try {
|
|
82
|
+
const initial = openSync(
|
|
83
|
+
path,
|
|
84
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
85
|
+
0o600,
|
|
86
|
+
);
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(
|
|
89
|
+
initial,
|
|
90
|
+
JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
|
|
91
|
+
);
|
|
92
|
+
} finally {
|
|
93
|
+
closeSync(initial);
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
fd = openSync(
|
|
100
|
+
path,
|
|
101
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
102
|
+
);
|
|
103
|
+
const stat = fstatSync(fd);
|
|
104
|
+
if (
|
|
105
|
+
!stat.isFile() ||
|
|
106
|
+
stat.nlink !== 1 ||
|
|
107
|
+
stat.size > 1024 * 1024 ||
|
|
108
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
109
|
+
)
|
|
110
|
+
throw new Error();
|
|
111
|
+
if (process.platform !== "win32") fchmodSync(fd, 0o600);
|
|
112
|
+
const raw = readFileSync(fd, "utf8");
|
|
113
|
+
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error();
|
|
114
|
+
return { config: parseConfig(raw), raw };
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(CONFIG_ERROR);
|
|
117
|
+
} finally {
|
|
118
|
+
if (fd !== undefined) closeSync(fd);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { loadConfig } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
export const REQUEST_ERROR =
|
|
6
|
+
"protecter: 请求已清空。配置无效、扫描超时或包含不支持的附件;修复后重试。";
|
|
7
|
+
export class Protecter {
|
|
8
|
+
private readonly tokens = new Map<string, string>();
|
|
9
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
10
|
+
private readonly workers = new Set<Worker>();
|
|
11
|
+
private closed = false;
|
|
12
|
+
constructor(
|
|
13
|
+
readonly configPath: string,
|
|
14
|
+
private readonly timeoutMs = 2000,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
initialize(): void {
|
|
18
|
+
loadConfig(this.configPath, true);
|
|
19
|
+
}
|
|
20
|
+
get mappingCount(): number {
|
|
21
|
+
return this.tokens.size;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Serialise scans so two requests cannot assign different tokens to the same value. */
|
|
25
|
+
redact(payload: unknown): Promise<unknown> {
|
|
26
|
+
const run = this.queue.then(() => this.scan(payload));
|
|
27
|
+
this.queue = run.catch(() => undefined);
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async scan(payload: unknown): Promise<unknown> {
|
|
32
|
+
if (this.closed) throw new Error(REQUEST_ERROR);
|
|
33
|
+
const { config, raw } = loadConfig(this.configPath);
|
|
34
|
+
const serialized = JSON.stringify(payload);
|
|
35
|
+
if (!serialized || Buffer.byteLength(serialized) > 8 * 1024 * 1024)
|
|
36
|
+
throw new Error(REQUEST_ERROR);
|
|
37
|
+
let snapshot: unknown;
|
|
38
|
+
try {
|
|
39
|
+
snapshot = JSON.parse(serialized);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(REQUEST_ERROR);
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let settled = false;
|
|
45
|
+
const worker = new Worker(
|
|
46
|
+
fileURLToPath(new URL("./scan-worker.mjs", import.meta.url)),
|
|
47
|
+
{
|
|
48
|
+
workerData: {
|
|
49
|
+
payload: snapshot,
|
|
50
|
+
rules: config.sensitiveWords,
|
|
51
|
+
entries: [...this.tokens],
|
|
52
|
+
configRaw: raw,
|
|
53
|
+
},
|
|
54
|
+
resourceLimits: { maxOldGenerationSizeMb: 128 },
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
this.workers.add(worker);
|
|
58
|
+
const finish = (data?: {
|
|
59
|
+
payload: unknown;
|
|
60
|
+
additions: [string, string][];
|
|
61
|
+
}) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
this.workers.delete(worker);
|
|
66
|
+
void worker.terminate();
|
|
67
|
+
if (!data || this.closed) {
|
|
68
|
+
reject(new Error(REQUEST_ERROR));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const [token, original] of data.additions)
|
|
72
|
+
this.tokens.set(token, original);
|
|
73
|
+
resolve(data.payload);
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(() => finish(), this.timeoutMs);
|
|
76
|
+
worker.once("message", (data) => finish(data.failed ? undefined : data));
|
|
77
|
+
worker.once("error", () => finish());
|
|
78
|
+
worker.once("exit", () => finish());
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
restoreText(text: string): string {
|
|
83
|
+
// One pass, never recursively expand text introduced by a replacement.
|
|
84
|
+
return text.replace(
|
|
85
|
+
/__PIP_[a-f0-9]{48}__/g,
|
|
86
|
+
(token) => this.tokens.get(token) ?? token,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
restore<T>(value: T, depth = 0): T {
|
|
91
|
+
if (depth > 80) throw new Error("protecter: 响应层级过深。");
|
|
92
|
+
if (typeof value === "string") return this.restoreText(value) as T;
|
|
93
|
+
if (Array.isArray(value))
|
|
94
|
+
return value.map((item) => this.restore(item, depth + 1)) as T;
|
|
95
|
+
if (value && typeof value === "object") {
|
|
96
|
+
const result: Record<string, unknown> = Object.create(null);
|
|
97
|
+
for (const [key, item] of Object.entries(value)) {
|
|
98
|
+
const nextKey = this.restoreText(key);
|
|
99
|
+
if (Object.hasOwn(result, nextKey))
|
|
100
|
+
throw new Error("protecter: 响应键冲突。");
|
|
101
|
+
result[nextKey] = this.restore(item, depth + 1);
|
|
102
|
+
}
|
|
103
|
+
return result as T;
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
close(): void {
|
|
109
|
+
this.closed = true;
|
|
110
|
+
this.tokens.clear();
|
|
111
|
+
for (const worker of this.workers) void worker.terminate();
|
|
112
|
+
this.workers.clear();
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
function normalized(path: string, cwd: string): string {
|
|
7
|
+
let value = path.replace(/^@/, "");
|
|
8
|
+
if (value.startsWith("file://")) value = fileURLToPath(value);
|
|
9
|
+
if (value === "~" || value.startsWith("~/"))
|
|
10
|
+
value = homedir() + value.slice(1);
|
|
11
|
+
return resolve(cwd, value);
|
|
12
|
+
}
|
|
13
|
+
function canonical(path: string): string {
|
|
14
|
+
try {
|
|
15
|
+
return realpathSync(path);
|
|
16
|
+
} catch {
|
|
17
|
+
const parent = dirname(path);
|
|
18
|
+
return parent === path
|
|
19
|
+
? path
|
|
20
|
+
: resolve(canonical(parent), relative(parent, path));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function contains(parent: string, child: string): boolean {
|
|
24
|
+
const rel = relative(parent, child);
|
|
25
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
26
|
+
}
|
|
27
|
+
function sameFile(a: string, b: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
const x = statSync(a),
|
|
30
|
+
y = statSync(b);
|
|
31
|
+
return x.dev === y.dev && x.ino === y.ino;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Best-effort access guard. Arbitrary shell/third-party tools are NOT sandboxed. */
|
|
38
|
+
export function blocksConfigAccess(
|
|
39
|
+
tool: string,
|
|
40
|
+
input: Record<string, unknown>,
|
|
41
|
+
cwd: string,
|
|
42
|
+
configPath: string,
|
|
43
|
+
): boolean {
|
|
44
|
+
const target = canonical(resolve(configPath));
|
|
45
|
+
const serialized = JSON.stringify(input);
|
|
46
|
+
if (/protecter\.json/i.test(serialized) || serialized.includes(configPath))
|
|
47
|
+
return true;
|
|
48
|
+
const candidate = input.path ?? input.file_path ?? input.filePath;
|
|
49
|
+
const recursive = ["grep", "find", "ls"].includes(tool);
|
|
50
|
+
if (typeof candidate !== "string" && !recursive) return false;
|
|
51
|
+
try {
|
|
52
|
+
const path = normalized(
|
|
53
|
+
typeof candidate === "string" ? candidate : ".",
|
|
54
|
+
cwd,
|
|
55
|
+
);
|
|
56
|
+
const real = canonical(path);
|
|
57
|
+
return (
|
|
58
|
+
real === target ||
|
|
59
|
+
sameFile(path, configPath) ||
|
|
60
|
+
(recursive && contains(real, target))
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
VERSION,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { loadConfig } from "./config.ts";
|
|
8
|
+
import { Protecter, REQUEST_ERROR } from "./engine.ts";
|
|
9
|
+
import { blocksConfigAccess } from "./guard.ts";
|
|
10
|
+
|
|
11
|
+
/** Explicitly install this after other extensions that rewrite outgoing payloads. */
|
|
12
|
+
export default function informationProtecter(pi: ExtensionAPI): void {
|
|
13
|
+
const engine = new Protecter(join(getAgentDir(), "protecter.json"));
|
|
14
|
+
const compatible =
|
|
15
|
+
/^0\.85\./.test(VERSION) && Number(VERSION.split(".")[2]) >= 1;
|
|
16
|
+
let healthy = false;
|
|
17
|
+
|
|
18
|
+
pi.on("session_start", (_event, ctx) => {
|
|
19
|
+
try {
|
|
20
|
+
if (!compatible) throw new Error();
|
|
21
|
+
engine.initialize();
|
|
22
|
+
healthy = true;
|
|
23
|
+
const count = loadConfig(engine.configPath).config.sensitiveWords.length;
|
|
24
|
+
ctx.ui.setStatus("protecter", `SPI Protecter · ${count} rules`);
|
|
25
|
+
if (count === 0)
|
|
26
|
+
ctx.ui.notify(
|
|
27
|
+
"SPI Protecter:敏感词数组为空,请在本地编辑 protecter.json 后使用。",
|
|
28
|
+
"warning",
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
healthy = false;
|
|
32
|
+
ctx.ui.notify(REQUEST_ERROR, "error");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
37
|
+
try {
|
|
38
|
+
if (!compatible) throw new Error();
|
|
39
|
+
const payload = await engine.redact(event.payload);
|
|
40
|
+
healthy = true;
|
|
41
|
+
return payload;
|
|
42
|
+
} catch {
|
|
43
|
+
healthy = false;
|
|
44
|
+
// Pi catches thrown hook errors and continues with the ORIGINAL payload.
|
|
45
|
+
// Always return an empty replacement first; abort/notifications are best-effort.
|
|
46
|
+
try {
|
|
47
|
+
await ctx.abort();
|
|
48
|
+
} catch {
|
|
49
|
+
/* replacement below remains mandatory */
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
ctx.ui.notify(REQUEST_ERROR, "error");
|
|
53
|
+
} catch {
|
|
54
|
+
/* never fail open */
|
|
55
|
+
}
|
|
56
|
+
return {}; // May produce a provider validation error, but contains NO original data.
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.on("message_end", (event) => {
|
|
61
|
+
if (event.message.role !== "assistant") return;
|
|
62
|
+
// Preserve IDs, signatures, usage and provider metadata; only restore user-facing content.
|
|
63
|
+
const message = event.message;
|
|
64
|
+
return {
|
|
65
|
+
message: {
|
|
66
|
+
...message,
|
|
67
|
+
content: message.content.map((block) => {
|
|
68
|
+
if (block.type === "text")
|
|
69
|
+
return { ...block, text: engine.restoreText(block.text) };
|
|
70
|
+
if (block.type === "thinking")
|
|
71
|
+
return { ...block, thinking: engine.restoreText(block.thinking) };
|
|
72
|
+
if (block.type === "toolCall")
|
|
73
|
+
return { ...block, arguments: engine.restore(block.arguments) };
|
|
74
|
+
return block;
|
|
75
|
+
}),
|
|
76
|
+
...(message.errorMessage
|
|
77
|
+
? { errorMessage: engine.restoreText(message.errorMessage) }
|
|
78
|
+
: {}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.registerMarkdownTransformer((text, context) =>
|
|
84
|
+
context.messageType === "user" ? text : engine.restoreText(text),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
pi.on("tool_call", (event, ctx) => {
|
|
88
|
+
if (!healthy) return { block: true, reason: "SPI Protecter 尚未就绪。" };
|
|
89
|
+
const restored = engine.restore(event.input);
|
|
90
|
+
for (const key of Object.keys(event.input))
|
|
91
|
+
delete (event.input as Record<string, unknown>)[key];
|
|
92
|
+
Object.assign(event.input, restored);
|
|
93
|
+
if (
|
|
94
|
+
blocksConfigAccess(
|
|
95
|
+
event.toolName,
|
|
96
|
+
event.input,
|
|
97
|
+
ctx.cwd,
|
|
98
|
+
engine.configPath,
|
|
99
|
+
)
|
|
100
|
+
) {
|
|
101
|
+
return {
|
|
102
|
+
block: true,
|
|
103
|
+
reason: "SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// These auxiliary request paths have different lifecycle/response restoration semantics.
|
|
109
|
+
// Until separately verified, cancel instead of silently sending plaintext summaries.
|
|
110
|
+
pi.on("session_before_compact", (_event, ctx) => {
|
|
111
|
+
ctx.ui.notify(
|
|
112
|
+
"SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。",
|
|
113
|
+
"warning",
|
|
114
|
+
);
|
|
115
|
+
return { cancel: true };
|
|
116
|
+
});
|
|
117
|
+
pi.on("session_before_tree", (event) =>
|
|
118
|
+
event.preparation.userWantsSummary ? { cancel: true } : undefined,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
pi.registerCommand("protecter", {
|
|
122
|
+
description: "SPI Protecter 状态与本地配置校验(不显示敏感值)",
|
|
123
|
+
handler: async (args, ctx) => {
|
|
124
|
+
if (args.trim() && !["status", "reload"].includes(args.trim())) {
|
|
125
|
+
ctx.ui.notify(
|
|
126
|
+
"用法:/protecter [status|reload]。不要在命令参数中填写敏感值。",
|
|
127
|
+
"info",
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
if (!compatible) throw new Error();
|
|
133
|
+
const { config } = loadConfig(engine.configPath);
|
|
134
|
+
healthy = true;
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
`SPI Protecter:${config.sensitiveWords.length} 条规则;${engine.mappingCount} 个内存映射。配置每次请求重新读取。`,
|
|
137
|
+
"info",
|
|
138
|
+
);
|
|
139
|
+
} catch {
|
|
140
|
+
healthy = false;
|
|
141
|
+
ctx.ui.notify(REQUEST_ERROR, "error");
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
pi.on("session_shutdown", () => engine.close());
|
|
146
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// A separate worker bounds user-supplied JavaScript regex execution (including ReDoS).
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const { payload, rules, entries, configRaw } = workerData;
|
|
7
|
+
const originalToToken = new Map(
|
|
8
|
+
entries.map(([token, original]) => [original, token]),
|
|
9
|
+
);
|
|
10
|
+
const tokenToOriginal = new Map(entries);
|
|
11
|
+
const additions = [];
|
|
12
|
+
const matchers = rules.map((rule) => {
|
|
13
|
+
if (typeof rule === "string") return { literal: rule };
|
|
14
|
+
if (rule.type === "literal") return { literal: rule.value };
|
|
15
|
+
return {
|
|
16
|
+
regex: new RegExp(
|
|
17
|
+
rule.pattern,
|
|
18
|
+
[...new Set((rule.flags ?? "") + "g")].join(""),
|
|
19
|
+
),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
// A restored value may lose its original regex context (e.g. a Bearer prefix).
|
|
23
|
+
// Keep learned originals protected for the lifetime of this extension instance.
|
|
24
|
+
for (const original of originalToToken.keys())
|
|
25
|
+
matchers.push({ literal: original });
|
|
26
|
+
// Defense in depth for an accidentally pasted full configuration (not a sandbox).
|
|
27
|
+
const compactConfig = JSON.stringify(JSON.parse(configRaw));
|
|
28
|
+
matchers.push({ literal: configRaw }, { literal: compactConfig });
|
|
29
|
+
const payloadText = JSON.stringify(payload);
|
|
30
|
+
let matches = 0;
|
|
31
|
+
let nodes = 0;
|
|
32
|
+
function tokenFor(original) {
|
|
33
|
+
let token = originalToToken.get(original);
|
|
34
|
+
if (token) return token;
|
|
35
|
+
if (tokenToOriginal.size >= 50000) throw new Error();
|
|
36
|
+
do {
|
|
37
|
+
token = `__PIP_${randomBytes(24).toString("hex")}__`;
|
|
38
|
+
} while (tokenToOriginal.has(token) || payloadText.includes(token));
|
|
39
|
+
originalToToken.set(original, token);
|
|
40
|
+
tokenToOriginal.set(token, original);
|
|
41
|
+
additions.push([token, original]);
|
|
42
|
+
return token;
|
|
43
|
+
}
|
|
44
|
+
function redactPlain(text) {
|
|
45
|
+
const spans = [];
|
|
46
|
+
for (const matcher of matchers) {
|
|
47
|
+
if (matcher.literal === undefined) {
|
|
48
|
+
matcher.regex.lastIndex = 0;
|
|
49
|
+
for (const match of text.matchAll(matcher.regex)) {
|
|
50
|
+
if (!match[0].length) throw new Error();
|
|
51
|
+
spans.push([match.index, match.index + match[0].length]);
|
|
52
|
+
if (++matches > 100000) throw new Error();
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
if (!matcher.literal) continue;
|
|
56
|
+
let start = 0;
|
|
57
|
+
while ((start = text.indexOf(matcher.literal, start)) !== -1) {
|
|
58
|
+
spans.push([start, start + matcher.literal.length]);
|
|
59
|
+
if (++matches > 100000) throw new Error();
|
|
60
|
+
start++; // Include overlapping occurrences.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
spans.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
65
|
+
const merged = [];
|
|
66
|
+
for (const span of spans) {
|
|
67
|
+
const prev = merged.at(-1);
|
|
68
|
+
if (prev && span[0] < prev[1]) prev[1] = Math.max(prev[1], span[1]);
|
|
69
|
+
else merged.push([...span]);
|
|
70
|
+
}
|
|
71
|
+
let result = "",
|
|
72
|
+
cursor = 0;
|
|
73
|
+
for (const [start, end] of merged) {
|
|
74
|
+
result += text.slice(cursor, start) + tokenFor(text.slice(start, end));
|
|
75
|
+
cursor = end;
|
|
76
|
+
}
|
|
77
|
+
return result + text.slice(cursor);
|
|
78
|
+
}
|
|
79
|
+
function redact(text) {
|
|
80
|
+
// Only OUR tokens are exempt, not arbitrary strings with a similar prefix.
|
|
81
|
+
let result = "",
|
|
82
|
+
cursor = 0;
|
|
83
|
+
for (const match of text.matchAll(/__PIP_[a-f0-9]{48}__/g)) {
|
|
84
|
+
if (!tokenToOriginal.has(match[0])) continue;
|
|
85
|
+
result += redactPlain(text.slice(cursor, match.index)) + match[0];
|
|
86
|
+
cursor = match.index + match[0].length;
|
|
87
|
+
}
|
|
88
|
+
return result + redactPlain(text.slice(cursor));
|
|
89
|
+
}
|
|
90
|
+
function walk(value, depth = 0) {
|
|
91
|
+
if (++nodes > 200000 || depth > 80) throw new Error();
|
|
92
|
+
if (typeof value === "string") {
|
|
93
|
+
if (value.includes(configRaw) || value.includes(compactConfig))
|
|
94
|
+
return redact(value);
|
|
95
|
+
// OpenAI serializes tool arguments as JSON strings: decode escapes BEFORE matching.
|
|
96
|
+
if (/^\s*[[{]/.test(value)) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(value);
|
|
100
|
+
} catch {
|
|
101
|
+
/* ordinary prose */
|
|
102
|
+
}
|
|
103
|
+
if (parsed && typeof parsed === "object") {
|
|
104
|
+
const inner = JSON.stringify(walk(parsed, depth + 1));
|
|
105
|
+
const outer = redact(inner);
|
|
106
|
+
if (outer === inner) return inner;
|
|
107
|
+
// A rule may span JSON syntax or match a whole JSON-shaped secret.
|
|
108
|
+
// Return altered JSON only if it still parses; otherwise reject the request.
|
|
109
|
+
JSON.parse(outer);
|
|
110
|
+
return outer;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return redact(value);
|
|
114
|
+
}
|
|
115
|
+
if (typeof value === "number") {
|
|
116
|
+
const transformed = redact(String(value));
|
|
117
|
+
return transformed === String(value) ? value : transformed;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(value)) return value.map((item) => walk(item, depth + 1));
|
|
120
|
+
if (value && typeof value === "object") {
|
|
121
|
+
// No claim to perform OCR or redact opaque attachments. Reject rather than leak.
|
|
122
|
+
if (
|
|
123
|
+
typeof value.type === "string" &&
|
|
124
|
+
/image|audio|video|document|(^|_)file($|_)/i.test(value.type)
|
|
125
|
+
)
|
|
126
|
+
throw new Error();
|
|
127
|
+
if (
|
|
128
|
+
Object.keys(value).some((key) =>
|
|
129
|
+
/^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(
|
|
130
|
+
key,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
throw new Error();
|
|
135
|
+
const result = Object.create(null);
|
|
136
|
+
for (const [key, item] of Object.entries(value)) {
|
|
137
|
+
const nextKey = redact(key);
|
|
138
|
+
if (Object.hasOwn(result, nextKey)) throw new Error();
|
|
139
|
+
result[nextKey] = walk(item, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
if (value === null || typeof value === "boolean") return value;
|
|
144
|
+
throw new Error();
|
|
145
|
+
}
|
|
146
|
+
parentPort.postMessage({ payload: walk(payload), additions });
|
|
147
|
+
} catch {
|
|
148
|
+
// Never serialize an exception, original payload or configuration back into diagnostics.
|
|
149
|
+
parentPort.postMessage({ failed: true });
|
|
150
|
+
}
|