pi-information-protecter 0.1.0 → 0.2.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 +48 -17
- package/README.md +118 -60
- package/SECURITY.md +50 -30
- package/docs/MIGRATION.md +54 -0
- package/package.json +38 -9
- package/src/config.ts +7 -3
- package/src/engine.ts +24 -8
- package/src/guard.ts +13 -5
- package/src/index.ts +31 -26
- package/src/machine.ts +28 -0
- package/src/migration.ts +127 -0
- package/src/scan-worker.mjs +16 -6
- package/src/validate-worker.mjs +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,23 +1,54 @@
|
|
|
1
|
-
# Changelog
|
|
1
|
+
# Changelog / 更新日志
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0 — Machine configuration and memory cache / 机器配置与内存缓存
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Derive deterministic filenames from OS machine identifiers using a product-specific hash; never fall back to random IDs.
|
|
6
|
+
使用产品专用哈希派生确定性机器配置名称,不回退随机标识。
|
|
7
|
+
- Validate and union-merge legacy/previous-machine configs before durable target writes and verified old-file deletion, with locking and retry-safe cleanup.
|
|
8
|
+
校验并合并旧名称及旧机器配置,持久化验证目标后删除旧文件,支持锁与可重试清理。
|
|
9
|
+
- Cache rules at startup/reload; outgoing requests and status no longer read configuration files. Loaded protection survives file edits or deletion.
|
|
10
|
+
启动和重载时缓存规则,出站请求和状态不再读配置;加载后的防护不受文件修改或删除影响。
|
|
11
|
+
- Make `/protecter reload` invoke Pi reload; check file-tool target paths instead of documentation content.
|
|
12
|
+
重载命令调用 Pi 重载,文件工具改为检查目标路径而非文档正文。
|
|
13
|
+
- Add migration, failure-injection, concurrency and cache regression tests, plus bilingual migration documentation.
|
|
14
|
+
增加迁移、故障注入、并发及缓存回归测试和双语迁移文档。
|
|
6
15
|
|
|
7
|
-
### 新增
|
|
8
16
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
- 配置文件 0600 权限及直接访问防护,配置异常与扫描超时的安全替代请求。
|
|
14
|
-
- Worker 正则扫描与资源限制;`/protecter status` 和 `/protecter reload`。
|
|
15
|
-
- 配置示例、安全边界说明及自动化测试,包括真实 Pi loader/runner 集成测试。
|
|
17
|
+
- Add tag-triggered GitHub Actions npm OIDC publishing, scoped environment permissions and manual dry-run validation.
|
|
18
|
+
增加标签触发的 GitHub Actions npm OIDC 发布、限定环境权限及手动试运行验证。
|
|
19
|
+
- Document trusted publisher setup and the automated release procedure in both languages.
|
|
20
|
+
以双语记录可信发布配置和自动发布流程。
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
## 0.1.0 — Initial release / 首个版本
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
+
**Sensitive Personal Information (SPI) Protecter — 个人敏感信息保护插件。**
|
|
25
|
+
|
|
26
|
+
### Added / 新增
|
|
27
|
+
|
|
28
|
+
- Local masking of final Pi provider request bodies, including system prompts, history, tool results and definitions.
|
|
29
|
+
本地脱敏 Pi 最终请求体,覆盖系统提示、历史、工具结果和工具定义。
|
|
30
|
+
- `protecter.json` sensitive-word arrays, literal matching and JavaScript regular expressions.
|
|
31
|
+
配置文件敏感词数组、字面匹配及 JavaScript 正则表达式。
|
|
32
|
+
- Random 192-bit placeholders, memory-only mappings and local final-response/tool-argument restoration.
|
|
33
|
+
随机 192-bit 占位符、仅内存映射及最终回复和工具参数本地还原。
|
|
34
|
+
- TUI Markdown streaming restoration and continued exact-value protection for learned secrets.
|
|
35
|
+
TUI Markdown 流式展示还原,以及已识别敏感值的持续精确匹配保护。
|
|
36
|
+
- Unix 0600 configuration permissions, direct-access guards and safe replacement payloads on configuration errors/timeouts.
|
|
37
|
+
配置文件 Unix 0600 权限、直接访问拦截,以及配置异常和超时时的安全替代请求体。
|
|
38
|
+
- Worker regex execution limits; `/protecter status` and `/protecter reload`.
|
|
39
|
+
worker 正则执行限制,以及状态和重载命令。
|
|
40
|
+
- Configuration examples, security guidance, bilingual documentation/comments and automated tests including real Pi loader/runner integration.
|
|
41
|
+
配置示例、安全说明、双语文档和注释,以及包含真实 Pi 加载器/执行器集成的自动化测试。
|
|
42
|
+
|
|
43
|
+
### Compatibility and limitations / 兼容性与限制
|
|
44
|
+
|
|
45
|
+
- Node.js 22+ and Pi `@earendil-works/pi-coding-agent` 0.85.1–0.85.x; tested with 0.85.1.
|
|
46
|
+
要求 Node.js 22+ 和该范围的 Pi,验证基线为 0.85.1。
|
|
47
|
+
- Empty initial rules require local configuration; not an automatic SPI detector.
|
|
48
|
+
初始规则为空,需本地配置,不自动识别全部个人敏感信息。
|
|
49
|
+
- Compaction, summarized tree navigation and common multimodal attachments are not supported.
|
|
50
|
+
暂不支持压缩、带摘要的树导航和常见多模态附件。
|
|
51
|
+
- RPC/JSON deltas keep placeholders until final restoration; mappings are not persisted across processes.
|
|
52
|
+
RPC/JSON 增量保留占位符直到最终还原,映射不跨进程保存。
|
|
53
|
+
- Not a sandbox: arbitrary shell encoding can bypass protection. Independent networking, authentication headers, local sessions and exports are outside coverage. See SECURITY.md.
|
|
54
|
+
不是沙箱,任意 shell 编码可绕过防护;独立联网、认证头、本地会话和导出不在保护范围,详见安全文档。
|
package/README.md
CHANGED
|
@@ -1,32 +1,57 @@
|
|
|
1
1
|
# pi-information-protecter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Sensitive Personal Information (SPI) Protecter — 个人敏感信息保护插件。**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A Pi extension that inspects outgoing LLM request bodies locally, replaces configured sensitive values with random placeholders, and restores echoed placeholders locally. Suitable for URLs, phone numbers, identity numbers, names, credentials and passwords. **This is rule-based accidental-disclosure protection, not a sandbox, automatic PII detector or encryption proxy.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
这是一个 Pi 扩展:在本地审查 LLM 出站请求体,用随机占位符替换配置中的敏感值,并在本地还原模型返回的占位符。适用于 URL、电话号码、身份证/ID、姓名、凭证和密码。**这是基于规则的意外泄漏防护,不是沙箱、自动 PII 检测器或加密代理。**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Installation / 安装
|
|
10
|
+
|
|
11
|
+
Requires Node.js 22+ and `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**, tested against 0.85.1. Legacy `@mariozechner/*` releases are not supported.
|
|
12
|
+
|
|
13
|
+
要求 Node.js 22+ 和 `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**,验证基线为 0.85.1。不支持旧 `@mariozechner/*` 版本。
|
|
10
14
|
|
|
11
15
|
```bash
|
|
12
|
-
#
|
|
13
|
-
pi install npm:pi-information-protecter@0.
|
|
16
|
+
# Install the published, pinned npm release. / 安装已发布的固定 npm 版本。
|
|
17
|
+
pi install npm:pi-information-protecter@0.2.0
|
|
14
18
|
|
|
15
|
-
#
|
|
19
|
+
# Alternatively, install from this repository without copying it. / 或从本仓库本地安装,不复制仓库。
|
|
16
20
|
pi install "$PWD"
|
|
17
|
-
|
|
21
|
+
|
|
22
|
+
# Load for one invocation. / 单次加载。
|
|
18
23
|
pi -e ./src/index.ts
|
|
19
24
|
```
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
Run `/reload` in an existing Pi session. Keep the complete `src/` directory when distributing this extension: the worker file is required, so copying only `index.ts` is insufficient.
|
|
27
|
+
|
|
28
|
+
已运行的 Pi 请执行 `/reload`。分发时保留完整 `src/` 目录;插件需要 worker 文件,不能仅复制 `index.ts`。
|
|
29
|
+
|
|
30
|
+
See [CHANGELOG.md](CHANGELOG.md) for version changes. Contribution and release instructions are in the repository's `CONTRIBUTING.md` and `docs/RELEASING.md`.
|
|
31
|
+
|
|
32
|
+
版本变更见 [CHANGELOG.md](CHANGELOG.md)。贡献与发布流程见仓库内的 `CONTRIBUTING.md` 和 `docs/RELEASING.md`。
|
|
33
|
+
|
|
34
|
+
Maintainers publish new versions by pushing matching `v*` tags. GitHub Actions validates and publishes through npm OIDC trusted publishing without a long-lived token. Manual dry runs do not publish.
|
|
35
|
+
|
|
36
|
+
维护者通过推送匹配版本的 `v*` 标签发布新版本。GitHub Actions 验证后使用 npm OIDC 可信发布,不需要长期令牌;手动试运行不会发布。
|
|
37
|
+
|
|
38
|
+
Initialization uses `~/.pi/agent/protecter.<machineHash>.json`, respecting `PI_CODING_AGENT_DIR`. The hash is the first 32 hex characters of SHA-256 over a product prefix and normalized OS ID: macOS `IOPlatformUUID`, Linux `/etc/machine-id`, Windows `MachineGuid`. Missing/invalid IDs fail initialization, without random fallback. The hash is a stable identifier, not a secret. Project-local configuration is not loaded.
|
|
39
|
+
|
|
40
|
+
初始化使用 `~/.pi/agent/protecter.<machineHash>.json`,遵循 `PI_CODING_AGENT_DIR`。哈希由产品前缀与规范化系统标识计算 SHA-256,取前 32 位十六进制;macOS 使用 `IOPlatformUUID`、Linux 使用 `/etc/machine-id`、Windows 使用 `MachineGuid`。标识缺失或无效时拒绝初始化,不随机回退。哈希是稳定标识而非秘密,不加载项目级配置。
|
|
41
|
+
|
|
42
|
+
At startup and `/reload`, recognized old configs (including legacy `protecter.json`) are validated, union-merged and deduplicated into the current target. The target is durably written and verified before unchanged old files are deleted. Invalid sources abort migration without deleting originals. Unix permissions are `0600`; unsafe links, foreign ownership and oversized files are rejected. Windows users must configure ACLs separately. See [migration details](docs/MIGRATION.md).
|
|
43
|
+
|
|
44
|
+
启动和重载时,识别到的旧配置(包括原固定文件名)会先验证,再合并去重到当前目标;目标持久化写入并验证后才删除未变化的旧文件。无效来源会中止迁移而不删除原文件。Unix 权限为 `0600`,拒绝不安全链接、他人所有文件和超限文件;Windows 需自行设置 ACL。详见迁移文档。
|
|
22
45
|
|
|
23
|
-
|
|
46
|
+
Validated configuration is cached per extension instance. Outgoing scans and status commands never reread or stat configuration files. Editing, corrupting or deleting files after loading does not alter the active snapshot. Changes take effect on `/reload` (or `/protecter reload`, which invokes Pi reload). A new instance after session replacement also initializes once. If all configs are deleted, the next initialization creates an empty config and warns; the old memory does not survive teardown.
|
|
24
47
|
|
|
25
|
-
|
|
48
|
+
验证后的配置按扩展实例缓存。出站扫描及状态命令不重新读取或检查配置文件;加载后修改、损坏或删除文件不改变当前快照。修改在重载后生效;`/protecter reload` 会调用 Pi 重载,会话切换产生的新实例也初始化一次。若全部配置已删除,下次初始化会创建空配置并警告,旧内存不跨实例销毁保留。
|
|
26
49
|
|
|
27
|
-
##
|
|
50
|
+
## Configuration / 配置
|
|
28
51
|
|
|
29
|
-
|
|
52
|
+
**Edit the configuration in a local editor. Do not ask the model to enter real passwords or commit real configuration to Git.** The initial rule array is empty and provides no personal-information detection; the UI warns about this. The extension does not automatically identify all SPI.
|
|
53
|
+
|
|
54
|
+
**请用本地编辑器修改配置,不要让模型填写真实密码,也不要把真实配置提交到 Git。** 初始规则数组为空,不具备个人信息检测能力,界面会给出警告。插件不会自动识别所有 SPI。
|
|
30
55
|
|
|
31
56
|
```json
|
|
32
57
|
{
|
|
@@ -40,63 +65,96 @@ pi -e ./src/index.ts
|
|
|
40
65
|
}
|
|
41
66
|
```
|
|
42
67
|
|
|
43
|
-
|
|
68
|
+
More examples are in [`protecter.example.json`](protecter.example.json); they are not automatically enabled. Number patterns do not validate authenticity or check digits. Prefer exact values for names and passwords.
|
|
69
|
+
|
|
70
|
+
更多示例见 [`protecter.example.json`](protecter.example.json),不会自动启用。号码规则不验证真实性或校验位,姓名和密码建议使用精确值。
|
|
44
71
|
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
|
|
49
|
-
-
|
|
50
|
-
|
|
51
|
-
-
|
|
72
|
+
- **Literals:** strings and `literal` rules match all case-sensitive occurrences, without regex interpretation.
|
|
73
|
+
**字面规则:**字符串和 `literal` 按大小写敏感方式匹配全部出现位置,不作正则解释。
|
|
74
|
+
- **Regex:** JavaScript regex replaces the entire match. Use lookbehind to match only a credential value. Flags `g i m s u` are supported; `g` is added automatically. `/pattern/flags` shorthand is not supported.
|
|
75
|
+
**正则:**JavaScript 正则替换整个匹配,可用后行断言仅匹配凭证值。支持 `g i m s u`,自动补 `g`,不支持 `/pattern/flags` 简写。
|
|
76
|
+
- Escape backslashes as `\\` in JSON. Empty-string matches are invalid; runtime zero-width matches reject the request.
|
|
77
|
+
JSON 中的反斜杠写作 `\\`。不允许匹配空字符串;运行时零宽匹配会拒绝请求。
|
|
78
|
+
- Overlapping matches are merged to avoid exposing a longer secret's suffix. Equal values reuse a token within the extension instance.
|
|
79
|
+
合并重叠匹配,避免暴露长敏感值的后缀;同一实例内相同原文复用占位符。
|
|
80
|
+
- Learned originals remain exact-match protected within the instance even if regex context disappears or a rule is removed. Exit/reload clears these records.
|
|
81
|
+
已识别原文在当前实例内持续受到精确匹配保护,即使正则上下文消失或规则被删除;退出或重载清除记录。
|
|
82
|
+
- `/protecter` and `/protecter status` report cached counts only, without disk access or sensitive values. `/protecter reload` waits for idle and invokes Pi's full reload; it is no longer a status-only command. Adding secrets through command arguments is not supported.
|
|
83
|
+
状态命令只报告缓存数量,不访问磁盘或显示敏感值。重载命令等待空闲并调用 Pi 完整重载,不再只是查询状态;不支持通过命令参数添加秘密。
|
|
84
|
+
- Limits: 1000 rules, 8192 characters per literal/pattern, 2-second scan timeout and 8 MiB request JSON. Exceeding limits rejects the request rather than sending plaintext.
|
|
85
|
+
限制为 1000 条规则、每个词或模式 8192 字符、扫描 2 秒、请求 JSON 8 MiB;超限拒绝,不降级发送明文。
|
|
52
86
|
|
|
53
|
-
## 工作方式
|
|
87
|
+
## How it works / 工作方式
|
|
54
88
|
|
|
55
89
|
```text
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
Input / history / tools / system prompt / tool definitions
|
|
91
|
+
输入/历史/工具结果/系统提示/工具定义
|
|
92
|
+
↓
|
|
93
|
+
before_provider_request: final JSON text scan / 最终 JSON 文本扫描
|
|
94
|
+
↓
|
|
95
|
+
__PIP_<192-bit random hex>__ → LLM gateway / LLM 网关
|
|
96
|
+
↓
|
|
97
|
+
Local response and tool-argument restoration / 本地回复和工具参数还原
|
|
63
98
|
```
|
|
64
99
|
|
|
65
|
-
1.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
1. Scan JSON strings, keys and numbers, including decoded JSON-string tool arguments. Local user input and original history are unchanged.
|
|
101
|
+
扫描 JSON 字符串、键和数字,包括解码后的 JSON 字符串工具参数;不修改本地用户输入和历史原文。
|
|
102
|
+
2. Generate tokens with `crypto.randomBytes(24)`. Mappings stay **in memory only**, never in configuration, session custom entries, logs or model prompts. Local users can still see original values.
|
|
103
|
+
使用 `crypto.randomBytes(24)` 生成占位符。映射**仅存于内存**,不写入配置、会话自定义条目、日志或模型提示;本地用户仍可看到原文。
|
|
104
|
+
3. Restore finalized assistant text, thinking and tool arguments. The TUI Markdown transformer restores complete streamed tokens; partial tokens may briefly appear. RPC/JSON deltas remain masked until final `message_end`. Altered or truncated tokens cannot be restored.
|
|
105
|
+
还原最终 assistant 文本、思考内容和工具参数。TUI 在完整占位符到达后还原,片段可能短暂显示;RPC/JSON 增量仍脱敏,直到最终消息还原。被改写或截断的占位符无法还原。
|
|
106
|
+
4. Restore tool arguments before execution so legitimate local operations use original values; redact them again on subsequent requests. **Restoring credentials is not tool authorization or exfiltration prevention.**
|
|
107
|
+
工具执行前还原参数,让合法本地操作使用原值;后续请求再次脱敏。**凭证还原不等于工具授权或防外传。**
|
|
108
|
+
5. Execute regex scans in a terminable worker. On failure, return `{}` and request cancellation rather than relying on hook exceptions swallowed by Pi. An empty request or provider validation error may still occur, but the handler does not return the original payload.
|
|
109
|
+
正则扫描运行于可终止 worker。失败时返回 `{}` 并请求取消,不依赖被 Pi 吞掉的钩子异常;仍可能产生空请求或提供商校验错误,但处理器不返回原始请求体。
|
|
110
|
+
|
|
111
|
+
### Configuration access guard / 配置访问防护
|
|
112
|
+
|
|
113
|
+
**Outgoing redaction is the main defense; direct-access blocking is supplementary.** Shell is not disabled wholesale.
|
|
114
|
+
|
|
115
|
+
**以出站脱敏为主,直接访问拦截为辅。** 不全面禁用 shell。
|
|
116
|
+
|
|
117
|
+
- File tools check actual path arguments, not document bodies; writing documentation that mentions config filenames is allowed. In the active config directory, legacy/current hash filenames and migration artifacts are protected. Shell command fields retain best-effort filename checks, so shell mentions can still produce false positives.
|
|
118
|
+
文件工具检查实际路径参数而非正文,允许文档提及配置名称。当前配置目录内的旧名称、哈希名称及迁移文件受保护;shell 命令字段保留尽力文件名检查,因此 shell 提及名称仍可能误拦截。
|
|
119
|
+
- Resolve `@`, `~`, relative paths and `file://`; check symlink/inode aliases. Block `grep/find/ls` over ancestor directories containing the configuration.
|
|
120
|
+
解析上述路径形式,检查符号链接和 inode 别名;阻止这些搜索工具扫描包含配置的祖先目录。
|
|
121
|
+
- Attempt whole-text masking if the complete original configuration accidentally appears in a request.
|
|
122
|
+
完整原始配置意外出现在请求文本时,尝试整体替换。
|
|
123
|
+
- **Best effort only:** dynamic shell, base64, split output, nested symlinks, arbitrary third-party tools and network sends can bypass these checks. This cannot guarantee that a malicious model never obtains configuration.
|
|
124
|
+
**仅为尽力防护:**动态 shell、编码、拆分输出、目录内链接、任意第三方工具和网络发送可绕过检查,无法保证恶意模型永远无法取得配置。
|
|
125
|
+
|
|
126
|
+
## Limitations — read first / 当前限制——请先阅读
|
|
127
|
+
|
|
128
|
+
- Only rule-matching text is protected, not paraphrases, split characters or arbitrary encodings. Common image/audio/video/file blocks reject the entire request because SPI cannot be reliably inspected; no OCR is provided.
|
|
129
|
+
仅保护命中规则的文本,不覆盖改写、拆字和任意编码。常见多模态附件因无法可靠审查而拒绝整个请求,不提供 OCR。
|
|
130
|
+
- `/compact`, automatic compaction and summarized `/tree` navigation are cancelled pending separate lifecycle/restoration verification. Tree navigation without summaries works; use `/new` for long sessions.
|
|
131
|
+
暂时取消压缩和带摘要的树导航,等待单独验证生命周期及还原;不带摘要的导航可用,长会话请使用 `/new`。
|
|
132
|
+
- Exit, reload and session switching discard mappings. Final restored local messages remain readable; crash leftovers, old summaries and raw deltas cannot recover tokens across restarts.
|
|
133
|
+
退出、重载和切换会话会丢弃映射。最终已还原消息仍可读,但崩溃残留、旧摘要和原始增量无法跨重启恢复占位符。
|
|
134
|
+
- Broad rules such as `.` or all digits may alter protocol fields, model names, tool schemas or IDs and break requests. Matching numbers become token strings. Prefer precise rules.
|
|
135
|
+
过宽规则可能改写协议字段、模型名、工具结构和 ID,导致请求失败;命中数字变为占位符字符串,应优先使用精确规则。
|
|
136
|
+
- Reused tokens reveal equality relationships; context may imply identity. This is not formal anonymization.
|
|
137
|
+
复用占位符会暴露值相等的关系,上下文也可能揭示身份,这不是形式化匿名化。
|
|
138
|
+
- Hooks do not cover HTTP headers, authentication keys, gateway endpoints, Pi telemetry, `/share`, independent extension SDK/fetch calls or tool networking. The gateway still receives its authentication credentials.
|
|
139
|
+
钩子不覆盖认证头、API key、网关地址、遥测、分享、扩展独立网络调用和工具联网;网关仍收到其认证凭证。
|
|
140
|
+
- **Load after other payload rewriters and trust every installed extension.** Later handlers can reintroduce originals. Extensions have local permissions, and Pi may continue if this extension fails to load. Check status before entering secrets.
|
|
141
|
+
**在其他请求改写扩展之后加载,并只使用可信扩展。** 后续处理器可重新加入原文;扩展有本机权限,插件加载失败时 Pi 可能继续运行,请先检查状态再输入秘密。
|
|
142
|
+
- Local sessions, terminals, exports and files written by tools may contain plaintext. They are not encrypted or cleaned by this project.
|
|
143
|
+
本地会话、终端、导出和工具写出的文件可能包含明文,本项目不加密或清理它们。
|
|
144
|
+
|
|
145
|
+
See [SECURITY.md](SECURITY.md) for the threat model. / 详细威胁模型见该安全文档。
|
|
146
|
+
|
|
147
|
+
## Development and verification / 开发与验证
|
|
94
148
|
|
|
95
149
|
```bash
|
|
96
150
|
npm ci
|
|
97
151
|
npm run check
|
|
98
152
|
```
|
|
99
153
|
|
|
100
|
-
|
|
154
|
+
Tests use temporary directories and fictional secrets, never real user configuration, API keys or LLM gateways. Coverage includes masking/restoration, regex timeout, permissions, path guards and real Pi loader/runner integration. All Markdown, comments and new commit messages must be English + Simplified Chinese.
|
|
155
|
+
|
|
156
|
+
测试仅使用临时目录和虚构敏感值,不读取真实配置、不使用真实密钥或访问 LLM 网关,覆盖脱敏还原、正则超时、权限、路径防护和真实 Pi 加载器/执行器集成。所有 Markdown、注释和新提交信息必须使用英文+简体中文。
|
|
157
|
+
|
|
158
|
+
API references: [Pi Extensions](https://pi.dev/docs/latest/extensions), local 0.85.1 documentation and runner implementation. Licensed under MIT.
|
|
101
159
|
|
|
102
|
-
|
|
160
|
+
接口依据为 Pi 官方扩展文档、本机 0.85.1 文档及执行器实现;采用 MIT 许可证。
|
package/SECURITY.md
CHANGED
|
@@ -1,30 +1,50 @@
|
|
|
1
|
-
# SPI Protecter 安全边界
|
|
2
|
-
|
|
3
|
-
## 目标
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
1.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
1
|
+
# SPI Protecter security boundaries / SPI Protecter 安全边界
|
|
2
|
+
|
|
3
|
+
## Goal / 目标
|
|
4
|
+
|
|
5
|
+
Prevent configured personal information from accidentally entering Pi LLM request bodies as plaintext. This assumes trusted Pi core, provider implementations, OS and installed extensions, successful extension loading and invocation of the final request hook.
|
|
6
|
+
|
|
7
|
+
防止已配置的个人信息意外以明文进入 Pi 的 LLM 请求体。前提是 Pi 核心、提供商实现、操作系统和已加载扩展可信,且插件成功加载、最终请求钩子确实执行。
|
|
8
|
+
|
|
9
|
+
## Non-goals / 非目标
|
|
10
|
+
|
|
11
|
+
- No OS sandbox or defense against same-UID programs, malicious extensions, arbitrary code execution, debuggers or memory inspection.
|
|
12
|
+
不提供操作系统沙箱,不防御同 UID 程序、恶意扩展、任意代码执行、调试器或内存检查。
|
|
13
|
+
- No protection against arbitrary encoding, splitting, translation or screenshot/OCR reconstruction; no automatic privacy classification or formal anonymization.
|
|
14
|
+
不防御任意编码、拆分、翻译和截图/OCR 重构,不提供自动隐私分类或形式化匿名化。
|
|
15
|
+
- No coverage for gateway authentication headers/endpoints, tool requests, independent SDK calls, telemetry, session exports or `/share`.
|
|
16
|
+
不覆盖网关认证头与地址、工具请求、独立 SDK 调用、遥测、会话导出或分享。
|
|
17
|
+
- No local configuration/session/terminal encryption, nor automatic prevention of exfiltration after a model infers a secret.
|
|
18
|
+
不加密本地配置、会话和终端,也不会在模型推断出秘密后自动阻止外传。
|
|
19
|
+
|
|
20
|
+
## Implementation / 实现策略
|
|
21
|
+
|
|
22
|
+
- Scan outgoing payload text; use random 192-bit placeholders and memory-only reverse mappings.
|
|
23
|
+
扫描出站请求体文本,使用随机 192-bit 占位符及仅内存反向映射。
|
|
24
|
+
- Limit worker regex scans to 2 seconds, with resource and input limits. Errors exclude original configuration, rules and request text.
|
|
25
|
+
worker 正则扫描限制为 2 秒,并限制资源和输入;错误不包含原始配置、规则或请求文本。
|
|
26
|
+
- Pi 0.85.1 catches `before_provider_request` errors and continues with the current payload. Error paths therefore return `{}` and attempt cancellation. This does not guarantee zero HTTP requests; it prevents this handler from falling back to the original payload.
|
|
27
|
+
Pi 0.85.1 会捕获该钩子异常并继续使用当前请求体,因此错误路径返回空对象并尝试取消。这不保证没有 HTTP 请求,只保证本处理器不回退原始请求体。
|
|
28
|
+
- Create configuration separately with Unix mode 0600. Reject links, non-regular files, multiple links and files owned by others. Users remain responsible for directories and OS permissions; TOCTOU races are not prevented.
|
|
29
|
+
独立创建配置,Unix 权限为 0600;拒绝链接、非普通文件、多链接及他人所有文件。用户仍需维护目录和系统权限,无法防止检查与使用之间的竞态。
|
|
30
|
+
- Direct-access blocking is supplementary. File tools inspect target paths rather than document bodies. Shell can bypass string checks, and command mentions or ancestor directory checks can still cause false positives.
|
|
31
|
+
直接访问拦截只是补充;文件工具检查目标路径而非正文。shell 可绕过字符串检查,命令提及及祖先目录检查仍可能误拦截。
|
|
32
|
+
- Startup/reload validates and merges machine-hash configurations before caching. Requests use memory only; file deletion does not revoke cached secrets. Reload or process exit clears the instance. Migration uses commit-before-delete and a cooperating-process lock, not a multi-file transaction or protection against hostile local writers. Stale locks require local inspection; Windows does not provide POSIX directory fsync.
|
|
33
|
+
启动或重载时校验并合并机器哈希配置后缓存,请求只使用内存;文件删除不撤销缓存秘密,重载或退出才清理实例。迁移采用先提交后删除及协作进程锁,并非多文件事务或恶意本地写入防护;残留锁需本地检查,Windows 无 POSIX 目录同步。
|
|
34
|
+
- Whole-configuration text detection does not guarantee that every reformatted rule definition is masked.
|
|
35
|
+
整体配置文本检测不保证所有重新格式化的规则定义都被遮盖。
|
|
36
|
+
- Compaction and tree summaries are conservatively disabled. Do not widen Pi compatibility without verification.
|
|
37
|
+
保守禁用压缩和树摘要,未经验证不扩大 Pi 兼容范围。
|
|
38
|
+
|
|
39
|
+
## Deployment guidance / 安全部署建议
|
|
40
|
+
|
|
41
|
+
1. Configure precise rules locally and check status/counts. Empty rules provide no SPI detection.
|
|
42
|
+
在本地配置具体规则并检查状态和数量;空规则不具备个人信息检测能力。
|
|
43
|
+
2. Inspect requests using fictional data and a trusted test gateway; never test with real secrets.
|
|
44
|
+
使用虚构数据及可信测试网关检查请求,绝不用真实秘密测试。
|
|
45
|
+
3. Avoid log-upload, session-export and telemetry extensions; verify payload handler ordering.
|
|
46
|
+
避免日志上传、会话导出和遥测类扩展,确认请求处理器顺序。
|
|
47
|
+
4. Use containers, separate users/filesystems and network restrictions for high-risk environments. Keep configuration outside tool-accessible storage. This extension alone cannot enforce that isolation.
|
|
48
|
+
高风险环境使用容器、独立用户或文件系统及网络限制,将配置放在工具不可访问的位置;本扩展无法单独提供此隔离。
|
|
49
|
+
5. Never post real configuration, mappings, sessions or personal-information packet captures in public issues.
|
|
50
|
+
不在公共问题报告中提交真实配置、映射、会话或含个人信息的抓包。
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Configuration migration and cache / 配置迁移与缓存
|
|
2
|
+
|
|
3
|
+
## Identity and discovery / 标识与发现
|
|
4
|
+
|
|
5
|
+
The target is `protecter.<32-lowercase-hex-machine-hash>.json` in Pi's active agent directory. Normalize the OS identifier by trimming, lowercasing and removing UUID hyphens, then hash `pi-information-protecter:v1:` plus that identifier with SHA-256. Use the first 32 hex characters. IDs are never logged. OS reinstalls, cloned images and containers may change or duplicate IDs; this is not a uniqueness or access-control guarantee.
|
|
6
|
+
|
|
7
|
+
目标位于 Pi 当前配置目录,名称包含 32 位小写十六进制机器哈希。系统标识去除首尾空白、转小写并移除 UUID 连字符,再加产品前缀计算 SHA-256,取前 32 位。不记录原始标识;重装、克隆和容器可能改变或重复标识,因此不保证唯一性,也不是访问控制。
|
|
8
|
+
|
|
9
|
+
Only `protecter.json` and `protecter.[a-f0-9]{32}.json` in that directory are candidates. No recursion or arbitrary wildcard deletion. At most 64 candidates are accepted. Example files are ignored. UUID filenames were never shipped and are not silently imported.
|
|
10
|
+
|
|
11
|
+
仅识别该目录中的旧固定名称和严格匹配的哈希名称,不递归、不任意通配删除,最多接受 64 个候选。示例文件被忽略;从未发布过 UUID 名称版本,因此不静默导入这种文件。
|
|
12
|
+
|
|
13
|
+
## Validate and merge / 校验与合并
|
|
14
|
+
|
|
15
|
+
Every candidate must be a current-user regular file without symlinks or hard links, at most 1 MiB, with schema version 1 and valid rules. Regex empty-match checks run in a bounded worker before any target write. Unknown fields, malformed rules and unsupported versions stop initialization. Existing files may have permissions tightened to 0600, but invalid input never causes deletion.
|
|
16
|
+
|
|
17
|
+
每个候选必须为当前用户所有的普通文件,无符号或硬链接,不超过 1 MiB,版本为 1 且规则合法。正则空匹配在限时 worker 中验证后才写目标。未知字段、错误规则和不支持版本会停止初始化。已有文件权限可能收紧为 0600,但无效输入不导致删除。
|
|
18
|
+
|
|
19
|
+
Current-target rules come first, then old files in filename order. Strings and equivalent explicit literals deduplicate by exact value. Regexes deduplicate by exact pattern and normalized flags, with implicit `g` included. Case, whitespace, Unicode and differing regex semantics are preserved. The merged, pretty-printed target must remain within 1000 rules and 1 MiB; no truncation is allowed.
|
|
20
|
+
|
|
21
|
+
当前目标规则优先,旧文件按名称顺序合并。字符串和等价字面对象按精确值去重;正则按模式和含隐式 `g` 的规范化标志去重。大小写、空白、Unicode 和不同正则语义保留。合并并格式化后仍须满足 1000 条及 1 MiB 限制,不允许截断。
|
|
22
|
+
|
|
23
|
+
## Commit and cleanup / 提交与清理
|
|
24
|
+
|
|
25
|
+
1. Acquire the directory-wide `.protecter-migration.lock` using exclusive directory creation; wait up to 5 seconds for cooperating processes.
|
|
26
|
+
通过排他目录创建获取目录级迁移锁,最多等待其他协作进程 5 秒。
|
|
27
|
+
2. Read and validate all candidates, retain file identity/content digests, merge and validate the result.
|
|
28
|
+
读取校验全部候选,记录身份及内容摘要,合并并校验结果。
|
|
29
|
+
3. Write a same-directory, uniquely named `.protecter-migration.<uuid>.tmp` with 0600 permissions; fsync and close it.
|
|
30
|
+
在同目录排他创建唯一临时文件,设为 0600,写入、同步并关闭。
|
|
31
|
+
4. Recheck candidate names, identities and content. Rename the temporary file onto the target; fsync the directory on POSIX. Reopen and verify the exact committed contents.
|
|
32
|
+
再检查候选集合、身份及内容,将临时文件重命名到目标,POSIX 上同步目录,重新打开验证精确内容。
|
|
33
|
+
5. Before each old-file deletion, recheck both the committed target and the old source. Delete only unchanged sources, then sync the directory and verify the final target.
|
|
34
|
+
每次删除前同时检查目标与旧来源,仅删除未变化来源,再同步目录并验证最终目标。
|
|
35
|
+
6. Activate the in-memory snapshot only after success; release the lock. Migration failure leaves the instance unready and outgoing requests safely empty.
|
|
36
|
+
成功后才启用内存快照并释放锁;失败时实例不就绪,出站请求安全清空。
|
|
37
|
+
|
|
38
|
+
This is recoverable, not a multi-file atomic transaction. A failure after target commit can leave both target and old files; the next run unions them again safely. Failure during deletion leaves the committed union intact. External editors do not obey the lock and TOCTOU cannot be eliminated; avoid editing during initialization. Windows lacks POSIX directory fsync, so power-loss durability is weaker.
|
|
39
|
+
|
|
40
|
+
这是一套可恢复流程,不是跨文件原子事务。目标提交后失败可能留下新旧文件,下次可安全重复合并;删除途中失败时目标已包含规则并集。外部编辑器不遵守锁,无法彻底消除竞态,请避免初始化期间编辑。Windows 缺少 POSIX 目录同步,断电持久性较弱。
|
|
41
|
+
|
|
42
|
+
A hard crash may leave the lock or a private temporary file. The plugin never steals a lock or automatically deletes unknown temporary files. Stop all Pi processes and inspect the agent directory locally before manually removing a stale lock or orphaned temporary file. Never upload those files: they may contain secrets.
|
|
43
|
+
|
|
44
|
+
硬崩溃可能遗留锁或私有临时文件。插件不抢占锁、不自动删除未知临时文件。先停止全部 Pi 进程,在本地检查目录后再手动移除残留锁或孤立临时文件;文件可能含秘密,绝不要上传。
|
|
45
|
+
|
|
46
|
+
## Memory lifecycle / 内存生命周期
|
|
47
|
+
|
|
48
|
+
Startup, `/reload` and extension-instance replacement initialize once. Outbound scans read only the cached snapshot; deleting or changing files does not affect the active instance. `/protecter status` reads only counts. `/protecter reload` requests Pi's full reload after idle. Successful reload uses disk rules; failed initialization does not fall back to plaintext. Cache and token mappings are cleared at shutdown.
|
|
49
|
+
|
|
50
|
+
启动、重载及扩展实例替换时初始化一次。出站扫描仅使用缓存,删除或修改文件不影响当前实例;状态命令只读取数量,重载命令等待空闲后请求 Pi 完整重载。成功重载使用磁盘规则,失败不回退明文;关闭时清除缓存和映射。
|
|
51
|
+
|
|
52
|
+
Deleting every config does not preserve rules across restart: a fresh instance creates an empty configuration and warns. Keep a secure user-managed backup if persistent recovery is required. Independent processes keep independent snapshots even if another process migrates their former file.
|
|
53
|
+
|
|
54
|
+
删除全部配置不能跨重启保留规则,新实例将创建空配置并警告;需要持久恢复时由用户自行安全备份。独立进程拥有独立快照,即使其他进程迁移原文件也不受影响。
|
package/package.json
CHANGED
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-information-protecter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Sensitive Personal Information (SPI) Protecter — local reversible privacy redaction for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/taills/pi-information-protecter.git"
|
|
10
|
+
},
|
|
8
11
|
"homepage": "https://github.com/taills/pi-information-protecter#readme",
|
|
9
|
-
"bugs": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/taills/pi-information-protecter/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"registry": "https://registry.npmjs.org/"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package",
|
|
21
|
+
"pi-extension",
|
|
22
|
+
"privacy",
|
|
23
|
+
"redaction"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"SECURITY.md",
|
|
29
|
+
"CHANGELOG.md",
|
|
30
|
+
"docs/MIGRATION.md",
|
|
31
|
+
"protecter.example.json",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./src/index.ts"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": ">=0.85.1 <0.86.0"
|
|
44
|
+
},
|
|
16
45
|
"devDependencies": {
|
|
17
46
|
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
18
47
|
"@types/node": "^22.0.0",
|
package/src/config.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface Config {
|
|
|
20
20
|
}
|
|
21
21
|
export const DEFAULT_CONFIG: Config = { version: 1, sensitiveWords: [] };
|
|
22
22
|
export const CONFIG_ERROR =
|
|
23
|
-
"
|
|
23
|
+
"SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";
|
|
24
24
|
|
|
25
25
|
export function parseConfig(raw: string): Config {
|
|
26
26
|
try {
|
|
@@ -60,7 +60,8 @@ export function parseConfig(raw: string): Config {
|
|
|
60
60
|
)
|
|
61
61
|
throw new Error();
|
|
62
62
|
const re = new RegExp(rule.pattern, rule.flags ?? "");
|
|
63
|
-
|
|
63
|
+
// Compilation only here; matching is bounded in the worker. / 此处仅编译,匹配在限时 worker 内执行。
|
|
64
|
+
void re;
|
|
64
65
|
} else throw new Error();
|
|
65
66
|
}
|
|
66
67
|
return value;
|
|
@@ -69,7 +70,10 @@ export function parseConfig(raw: string): Config {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Reject symlinks and hard links; never print parser/OS errors containing secrets.
|
|
75
|
+
* 拒绝符号链接和硬链接;不输出可能包含敏感值的解析器或系统错误。
|
|
76
|
+
*/
|
|
73
77
|
export function loadConfig(
|
|
74
78
|
path: string,
|
|
75
79
|
create = false,
|
package/src/engine.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { migrateConfig, type Snapshot } from "./migration.ts";
|
|
4
|
+
import { getMachineHash } from "./machine.ts";
|
|
4
5
|
|
|
5
6
|
export const REQUEST_ERROR =
|
|
6
7
|
"protecter: 请求已清空。配置无效、扫描超时或包含不支持的附件;修复后重试。";
|
|
@@ -9,19 +10,31 @@ export class Protecter {
|
|
|
9
10
|
private queue: Promise<unknown> = Promise.resolve();
|
|
10
11
|
private readonly workers = new Set<Worker>();
|
|
11
12
|
private closed = false;
|
|
13
|
+
private snapshot?: Snapshot;
|
|
12
14
|
constructor(
|
|
13
|
-
readonly
|
|
15
|
+
private readonly configDir: string,
|
|
14
16
|
private readonly timeoutMs = 2000,
|
|
17
|
+
private readonly machineId: () => string = getMachineHash,
|
|
15
18
|
) {}
|
|
16
19
|
|
|
17
|
-
initialize(): void {
|
|
18
|
-
|
|
20
|
+
async initialize(): Promise<void> {
|
|
21
|
+
if (this.closed) throw new Error(REQUEST_ERROR);
|
|
22
|
+
this.snapshot = undefined;
|
|
23
|
+
const snapshot = await migrateConfig(this.configDir, this.machineId());
|
|
24
|
+
if (this.closed) throw new Error(REQUEST_ERROR);
|
|
25
|
+
this.snapshot = snapshot;
|
|
19
26
|
}
|
|
27
|
+
get configPath(): string { return this.snapshot?.path ?? ""; }
|
|
28
|
+
get ruleCount(): number { return this.snapshot?.config.sensitiveWords.length ?? 0; }
|
|
29
|
+
get ready(): boolean { return !!this.snapshot && !this.closed; }
|
|
20
30
|
get mappingCount(): number {
|
|
21
31
|
return this.tokens.size;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Serialise scans so two requests cannot assign different tokens to the same value.
|
|
36
|
+
* 串行扫描,避免两个请求为同一原文分配不同占位符。
|
|
37
|
+
*/
|
|
25
38
|
redact(payload: unknown): Promise<unknown> {
|
|
26
39
|
const run = this.queue.then(() => this.scan(payload));
|
|
27
40
|
this.queue = run.catch(() => undefined);
|
|
@@ -29,8 +42,8 @@ export class Protecter {
|
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
private async scan(payload: unknown): Promise<unknown> {
|
|
32
|
-
if (this.
|
|
33
|
-
const { config,
|
|
45
|
+
if (!this.ready) throw new Error(REQUEST_ERROR);
|
|
46
|
+
const { config, sourceRaws } = this.snapshot!;
|
|
34
47
|
const serialized = JSON.stringify(payload);
|
|
35
48
|
if (!serialized || Buffer.byteLength(serialized) > 8 * 1024 * 1024)
|
|
36
49
|
throw new Error(REQUEST_ERROR);
|
|
@@ -49,8 +62,9 @@ export class Protecter {
|
|
|
49
62
|
payload: snapshot,
|
|
50
63
|
rules: config.sensitiveWords,
|
|
51
64
|
entries: [...this.tokens],
|
|
52
|
-
|
|
65
|
+
configRaws: sourceRaws,
|
|
53
66
|
},
|
|
67
|
+
execArgv: [],
|
|
54
68
|
resourceLimits: { maxOldGenerationSizeMb: 128 },
|
|
55
69
|
},
|
|
56
70
|
);
|
|
@@ -81,6 +95,7 @@ export class Protecter {
|
|
|
81
95
|
|
|
82
96
|
restoreText(text: string): string {
|
|
83
97
|
// One pass, never recursively expand text introduced by a replacement.
|
|
98
|
+
// 仅替换一遍,不递归展开替换后引入的文本。
|
|
84
99
|
return text.replace(
|
|
85
100
|
/__PIP_[a-f0-9]{48}__/g,
|
|
86
101
|
(token) => this.tokens.get(token) ?? token,
|
|
@@ -107,6 +122,7 @@ export class Protecter {
|
|
|
107
122
|
|
|
108
123
|
close(): void {
|
|
109
124
|
this.closed = true;
|
|
125
|
+
this.snapshot = undefined;
|
|
110
126
|
this.tokens.clear();
|
|
111
127
|
for (const worker of this.workers) void worker.terminate();
|
|
112
128
|
this.workers.clear();
|
package/src/guard.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { CONFIG_NAME } from "./migration.ts";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
6
7
|
function normalized(path: string, cwd: string): string {
|
|
@@ -34,17 +35,23 @@ function sameFile(a: string, b: string): boolean {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Best-effort access guard. Arbitrary shell/third-party tools are NOT sandboxed.
|
|
40
|
+
* 尽力而为的访问防护,不对任意 shell 或第三方工具提供沙箱隔离。
|
|
41
|
+
*/
|
|
38
42
|
export function blocksConfigAccess(
|
|
39
43
|
tool: string,
|
|
40
44
|
input: Record<string, unknown>,
|
|
41
45
|
cwd: string,
|
|
42
46
|
configPath: string,
|
|
43
47
|
): boolean {
|
|
48
|
+
if (!configPath) return true;
|
|
44
49
|
const target = canonical(resolve(configPath));
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
50
|
+
// Inspect executable fields, not document bodies. / 检查可执行字段,不检查文档正文。
|
|
51
|
+
if (["bash", "powershell"].includes(tool)) {
|
|
52
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
53
|
+
if (/protecter(?:\.[a-f0-9]{32})?\.json|\.protecter-migration/i.test(command) || command.includes(configPath)) return true;
|
|
54
|
+
}
|
|
48
55
|
const candidate = input.path ?? input.file_path ?? input.filePath;
|
|
49
56
|
const recursive = ["grep", "find", "ls"].includes(tool);
|
|
50
57
|
if (typeof candidate !== "string" && !recursive) return false;
|
|
@@ -55,6 +62,7 @@ export function blocksConfigAccess(
|
|
|
55
62
|
);
|
|
56
63
|
const real = canonical(path);
|
|
57
64
|
return (
|
|
65
|
+
(dirname(real) === dirname(target) && (CONFIG_NAME.test(basename(real)) || basename(real).startsWith(".protecter-migration"))) ||
|
|
58
66
|
real === target ||
|
|
59
67
|
sameFile(path, configPath) ||
|
|
60
68
|
(recursive && contains(real, target))
|
package/src/index.ts
CHANGED
|
@@ -3,28 +3,29 @@ import {
|
|
|
3
3
|
VERSION,
|
|
4
4
|
type ExtensionAPI,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { loadConfig } from "./config.ts";
|
|
8
6
|
import { Protecter, REQUEST_ERROR } from "./engine.ts";
|
|
9
7
|
import { blocksConfigAccess } from "./guard.ts";
|
|
10
8
|
|
|
11
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Explicitly install this after other extensions that rewrite outgoing payloads.
|
|
11
|
+
* 请将本扩展放在其他改写出站请求体的扩展之后加载。
|
|
12
|
+
*/
|
|
12
13
|
export default function informationProtecter(pi: ExtensionAPI): void {
|
|
13
|
-
const engine = new Protecter(
|
|
14
|
+
const engine = new Protecter(getAgentDir());
|
|
14
15
|
const compatible =
|
|
15
16
|
/^0\.85\./.test(VERSION) && Number(VERSION.split(".")[2]) >= 1;
|
|
16
17
|
let healthy = false;
|
|
17
18
|
|
|
18
|
-
pi.on("session_start", (_event, ctx) => {
|
|
19
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
19
20
|
try {
|
|
20
21
|
if (!compatible) throw new Error();
|
|
21
|
-
engine.initialize();
|
|
22
|
+
await engine.initialize();
|
|
22
23
|
healthy = true;
|
|
23
|
-
const count =
|
|
24
|
+
const count = engine.ruleCount;
|
|
24
25
|
ctx.ui.setStatus("protecter", `SPI Protecter · ${count} rules`);
|
|
25
26
|
if (count === 0)
|
|
26
27
|
ctx.ui.notify(
|
|
27
|
-
"SPI Protecter
|
|
28
|
+
"SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。",
|
|
28
29
|
"warning",
|
|
29
30
|
);
|
|
30
31
|
} catch {
|
|
@@ -41,25 +42,30 @@ export default function informationProtecter(pi: ExtensionAPI): void {
|
|
|
41
42
|
return payload;
|
|
42
43
|
} catch {
|
|
43
44
|
healthy = false;
|
|
44
|
-
// Pi catches
|
|
45
|
-
//
|
|
45
|
+
// Pi catches hook errors and continues with the ORIGINAL payload.
|
|
46
|
+
// Pi 会捕获钩子异常并继续使用原始请求体。
|
|
47
|
+
// Always return an empty replacement; abort/notifications are best-effort.
|
|
48
|
+
// 必须返回空替代请求体;终止请求和通知仅作尽力处理。
|
|
46
49
|
try {
|
|
47
50
|
await ctx.abort();
|
|
48
51
|
} catch {
|
|
49
|
-
/*
|
|
52
|
+
/* Replacement below remains mandatory. / 仍须返回下方的替代请求体。 */
|
|
50
53
|
}
|
|
51
54
|
try {
|
|
52
55
|
ctx.ui.notify(REQUEST_ERROR, "error");
|
|
53
56
|
} catch {
|
|
54
|
-
/*
|
|
57
|
+
/* Never fail open. / 不因异常放行原文。 */
|
|
55
58
|
}
|
|
56
|
-
|
|
59
|
+
// May produce a provider validation error, but contains NO original data.
|
|
60
|
+
// 可能触发提供商校验错误,但不包含任何原始数据。
|
|
61
|
+
return {};
|
|
57
62
|
}
|
|
58
63
|
});
|
|
59
64
|
|
|
60
65
|
pi.on("message_end", (event) => {
|
|
61
66
|
if (event.message.role !== "assistant") return;
|
|
62
67
|
// Preserve IDs, signatures, usage and provider metadata; only restore user-facing content.
|
|
68
|
+
// 保留 ID、签名、用量与提供商元数据,仅还原面向用户的内容。
|
|
63
69
|
const message = event.message;
|
|
64
70
|
return {
|
|
65
71
|
message: {
|
|
@@ -105,8 +111,10 @@ export default function informationProtecter(pi: ExtensionAPI): void {
|
|
|
105
111
|
}
|
|
106
112
|
});
|
|
107
113
|
|
|
108
|
-
// These auxiliary
|
|
114
|
+
// These auxiliary requests have different lifecycle/response restoration semantics.
|
|
115
|
+
// 这些辅助请求具有不同的生命周期与响应还原语义。
|
|
109
116
|
// Until separately verified, cancel instead of silently sending plaintext summaries.
|
|
117
|
+
// 在单独验证前取消这些请求,避免静默发送明文摘要。
|
|
110
118
|
pi.on("session_before_compact", (_event, ctx) => {
|
|
111
119
|
ctx.ui.notify(
|
|
112
120
|
"SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。",
|
|
@@ -119,7 +127,7 @@ export default function informationProtecter(pi: ExtensionAPI): void {
|
|
|
119
127
|
);
|
|
120
128
|
|
|
121
129
|
pi.registerCommand("protecter", {
|
|
122
|
-
description: "SPI Protecter
|
|
130
|
+
description: "SPI Protecter memory status / 内存状态(不显示敏感值)",
|
|
123
131
|
handler: async (args, ctx) => {
|
|
124
132
|
if (args.trim() && !["status", "reload"].includes(args.trim())) {
|
|
125
133
|
ctx.ui.notify(
|
|
@@ -128,18 +136,15 @@ export default function informationProtecter(pi: ExtensionAPI): void {
|
|
|
128
136
|
);
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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");
|
|
139
|
+
if (args.trim() === "reload") {
|
|
140
|
+
await ctx.waitForIdle();
|
|
141
|
+
await ctx.reload();
|
|
142
|
+
return;
|
|
142
143
|
}
|
|
144
|
+
ctx.ui.notify(
|
|
145
|
+
`SPI Protecter: ${engine.ready ? "ready / 就绪" : "not ready / 未就绪"}; ${engine.ruleCount} rules / 规则; ${engine.mappingCount} mappings / 映射. Memory snapshot; /reload to refresh / 内存快照,重载后更新。`,
|
|
146
|
+
engine.ready ? "info" : "error",
|
|
147
|
+
);
|
|
143
148
|
},
|
|
144
149
|
});
|
|
145
150
|
pi.on("session_shutdown", () => engine.close());
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export const MACHINE_ERROR = "SPI Protecter: machine ID unavailable / 无法获取机器标识。";
|
|
6
|
+
|
|
7
|
+
/** Normalize OS identifiers without exposing them. / 规范化系统标识,不向外暴露。 */
|
|
8
|
+
export function machineHash(id: string): string {
|
|
9
|
+
const normalized = id.trim().toLowerCase().replace(/-/g, "");
|
|
10
|
+
if (!/^[a-f0-9]{32}$/.test(normalized) || /^0+$/.test(normalized)) throw new Error(MACHINE_ERROR);
|
|
11
|
+
return createHash("sha256").update(`pi-information-protecter:v1:${normalized}`).digest("hex").slice(0, 32);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** No random fallback: unavailable IDs must fail closed. / 不随机回退,标识不可用时拒绝初始化。 */
|
|
15
|
+
export function getMachineHash(): string {
|
|
16
|
+
try {
|
|
17
|
+
if (process.platform === "linux") return machineHash(readFileSync("/etc/machine-id", "utf8"));
|
|
18
|
+
if (process.platform === "darwin") {
|
|
19
|
+
const text = execFileSync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
20
|
+
return machineHash(text.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1] ?? "");
|
|
21
|
+
}
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
const text = execFileSync("reg.exe", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid", "/reg:64"], { encoding: "utf8", timeout: 5000, maxBuffer: 65536, stdio: ["ignore", "pipe", "pipe"] });
|
|
24
|
+
return machineHash(text.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1] ?? "");
|
|
25
|
+
}
|
|
26
|
+
} catch { /* Do not expose command output. / 不暴露命令输出。 */ }
|
|
27
|
+
throw new Error(MACHINE_ERROR);
|
|
28
|
+
}
|
package/src/migration.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { Worker } from "node:worker_threads";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CONFIG_ERROR, DEFAULT_CONFIG, loadConfig, parseConfig, type Config, type Rule } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
export const CONFIG_NAME = /^protecter(?:\.[a-f0-9]{32})?\.json$/;
|
|
9
|
+
export interface Snapshot { path: string; config: Config; raw: string; sourceRaws: string[]; migrated: number }
|
|
10
|
+
interface Source { name: string; raw: string; config: Config; identity: string; digest: string }
|
|
11
|
+
export interface MigrationOptions {
|
|
12
|
+
lockTimeoutMs?: number;
|
|
13
|
+
// Local fault injection for regression tests only. / 仅供本地回归测试注入故障。
|
|
14
|
+
checkpoint?: (stage: "beforeCommit" | "afterCommit" | "beforeDelete", path: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validatePatterns(configs: Config[]): Promise<void> {
|
|
18
|
+
return new Promise((resolvePromise, reject) => {
|
|
19
|
+
const worker = new Worker(fileURLToPath(new URL("./validate-worker.mjs", import.meta.url)), { workerData: configs, execArgv: [], resourceLimits: { maxOldGenerationSizeMb: 64 } });
|
|
20
|
+
let done = false;
|
|
21
|
+
const finish = (ok: boolean) => {
|
|
22
|
+
if (done) return;
|
|
23
|
+
done = true;
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
void worker.terminate();
|
|
26
|
+
if (ok) resolvePromise(); else reject(new Error(CONFIG_ERROR));
|
|
27
|
+
};
|
|
28
|
+
const timer = setTimeout(() => finish(false), 2000);
|
|
29
|
+
worker.once("message", value => finish(value === true));
|
|
30
|
+
worker.once("error", () => finish(false));
|
|
31
|
+
worker.once("exit", () => finish(false));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mergeConfigs(configs: Config[]): Config {
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
const rules: Rule[] = [];
|
|
38
|
+
for (const config of configs) for (const rule of config.sensitiveWords) {
|
|
39
|
+
const key = typeof rule === "string" ? JSON.stringify(["literal", rule]) : rule.type === "literal" ? JSON.stringify(["literal", rule.value]) : JSON.stringify(["regex", rule.pattern, [...new Set((rule.flags ?? "") + "g")].sort().join("")]);
|
|
40
|
+
if (!seen.has(key)) { seen.add(key); rules.push(rule); }
|
|
41
|
+
}
|
|
42
|
+
const raw = JSON.stringify({ version: 1, sensitiveWords: rules });
|
|
43
|
+
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error(CONFIG_ERROR);
|
|
44
|
+
return parseConfig(raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function identity(path: string): string {
|
|
48
|
+
const s = fs.lstatSync(path, { bigint: true });
|
|
49
|
+
if (!s.isFile() || s.nlink !== 1n || (process.getuid && s.uid !== BigInt(process.getuid()))) throw new Error(CONFIG_ERROR);
|
|
50
|
+
return `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}`;
|
|
51
|
+
}
|
|
52
|
+
function source(dir: string, name: string): Source {
|
|
53
|
+
const path = join(dir, name), before = identity(path);
|
|
54
|
+
const loaded = loadConfig(path);
|
|
55
|
+
if (identity(path) !== before) throw new Error(CONFIG_ERROR);
|
|
56
|
+
return { name, ...loaded, identity: before, digest: createHash("sha256").update(loaded.raw).digest("hex") };
|
|
57
|
+
}
|
|
58
|
+
function unchanged(dir: string, previous: Source): void {
|
|
59
|
+
const now = source(dir, previous.name);
|
|
60
|
+
if (now.identity !== previous.identity || now.digest !== previous.digest) throw new Error(CONFIG_ERROR);
|
|
61
|
+
}
|
|
62
|
+
function names(dir: string): string[] { return fs.readdirSync(dir).filter(name => CONFIG_NAME.test(name)).sort(); }
|
|
63
|
+
function syncDir(dir: string): void {
|
|
64
|
+
// Windows does not support POSIX directory fsync. / Windows 不支持 POSIX 目录同步。
|
|
65
|
+
if (process.platform === "win32") return;
|
|
66
|
+
const fd = fs.openSync(dir, fs.constants.O_RDONLY);
|
|
67
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Commit before deleting; repeat safely after partial cleanup. / 先提交再删除,部分清理后可安全重试。 */
|
|
71
|
+
export async function migrateConfig(directory: string, hash: string, options: MigrationOptions = {}): Promise<Snapshot> {
|
|
72
|
+
if (!/^[a-f0-9]{32}$/.test(hash)) throw new Error(CONFIG_ERROR);
|
|
73
|
+
const dir = resolve(directory), targetName = `protecter.${hash}.json`, path = join(dir, targetName);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const lock = join(dir, ".protecter-migration.lock"), deadline = Date.now() + (options.lockTimeoutMs ?? 5000);
|
|
76
|
+
for (;;) {
|
|
77
|
+
try { fs.mkdirSync(lock, { mode: 0o700 }); break; }
|
|
78
|
+
catch (error) {
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || Date.now() >= deadline) throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");
|
|
80
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let temp: string | undefined;
|
|
84
|
+
try {
|
|
85
|
+
const discovered = names(dir);
|
|
86
|
+
if (discovered.length > 64) throw new Error(CONFIG_ERROR);
|
|
87
|
+
const ordered = [...discovered].sort((a, b) => a === targetName ? -1 : b === targetName ? 1 : a.localeCompare(b));
|
|
88
|
+
const sources = ordered.map(name => source(dir, name));
|
|
89
|
+
const config = mergeConfigs(sources.length ? sources.map(s => s.config) : [DEFAULT_CONFIG]);
|
|
90
|
+
await validatePatterns(sources.length ? sources.map(s => s.config) : [config]);
|
|
91
|
+
if (sources.length === 1 && sources[0].name === targetName) {
|
|
92
|
+
unchanged(dir, sources[0]);
|
|
93
|
+
if (JSON.stringify(names(dir)) !== JSON.stringify(discovered)) throw new Error(CONFIG_ERROR);
|
|
94
|
+
return { path, config: sources[0].config, raw: sources[0].raw, sourceRaws: [sources[0].raw], migrated: 0 };
|
|
95
|
+
}
|
|
96
|
+
const raw = JSON.stringify(config, null, 2) + "\n";
|
|
97
|
+
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error(CONFIG_ERROR);
|
|
98
|
+
temp = join(dir, `.protecter-migration.${randomUUID()}.tmp`);
|
|
99
|
+
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
100
|
+
try { fs.writeFileSync(fd, raw); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
101
|
+
options.checkpoint?.("beforeCommit", path);
|
|
102
|
+
if (JSON.stringify(names(dir)) !== JSON.stringify(discovered)) throw new Error(CONFIG_ERROR);
|
|
103
|
+
for (const s of sources) unchanged(dir, s);
|
|
104
|
+
fs.renameSync(temp, path);
|
|
105
|
+
temp = undefined;
|
|
106
|
+
syncDir(dir);
|
|
107
|
+
const committed = source(dir, targetName);
|
|
108
|
+
if (committed.raw !== raw) throw new Error(CONFIG_ERROR);
|
|
109
|
+
options.checkpoint?.("afterCommit", path);
|
|
110
|
+
for (const s of sources) {
|
|
111
|
+
if (s.name === targetName) continue;
|
|
112
|
+
options.checkpoint?.("beforeDelete", join(dir, s.name));
|
|
113
|
+
unchanged(dir, committed);
|
|
114
|
+
unchanged(dir, s);
|
|
115
|
+
fs.unlinkSync(join(dir, s.name));
|
|
116
|
+
}
|
|
117
|
+
syncDir(dir);
|
|
118
|
+
const expected = [targetName];
|
|
119
|
+
if (JSON.stringify(names(dir)) !== JSON.stringify(expected)) throw new Error(CONFIG_ERROR);
|
|
120
|
+
unchanged(dir, committed);
|
|
121
|
+
return { path, config, raw, sourceRaws: [...new Set([...sources.map(s => s.raw), raw])], migrated: sources.filter(s => s.name !== targetName).length };
|
|
122
|
+
} catch { throw new Error(CONFIG_ERROR); }
|
|
123
|
+
finally {
|
|
124
|
+
if (temp) { try { fs.unlinkSync(temp); } catch { /* Preserve on failure. / 失败时保留待人工检查。 */ } }
|
|
125
|
+
fs.rmdirSync(lock);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/scan-worker.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// A separate worker bounds user-supplied JavaScript regex execution (including ReDoS).
|
|
2
|
+
// 独立 worker 限制用户正则的执行时间,包括正则拒绝服务风险。
|
|
2
3
|
import { parentPort, workerData } from "node:worker_threads";
|
|
3
4
|
import { randomBytes } from "node:crypto";
|
|
4
5
|
|
|
5
6
|
try {
|
|
6
|
-
const { payload, rules, entries,
|
|
7
|
+
const { payload, rules, entries, configRaws } = workerData;
|
|
7
8
|
const originalToToken = new Map(
|
|
8
9
|
entries.map(([token, original]) => [original, token]),
|
|
9
10
|
);
|
|
@@ -20,12 +21,15 @@ try {
|
|
|
20
21
|
};
|
|
21
22
|
});
|
|
22
23
|
// A restored value may lose its original regex context (e.g. a Bearer prefix).
|
|
24
|
+
// 还原后的值可能失去原始正则上下文,例如 Bearer 前缀。
|
|
23
25
|
// Keep learned originals protected for the lifetime of this extension instance.
|
|
26
|
+
// 在当前扩展实例的整个生命周期内继续保护已识别的原文。
|
|
24
27
|
for (const original of originalToToken.keys())
|
|
25
28
|
matchers.push({ literal: original });
|
|
26
29
|
// Defense in depth for an accidentally pasted full configuration (not a sandbox).
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// 对意外粘贴的完整配置提供纵深防护,但这不是沙箱。
|
|
31
|
+
const protectedTexts = [...new Set(configRaws.flatMap(raw => [raw, JSON.stringify(JSON.parse(raw))]))];
|
|
32
|
+
for (const literal of protectedTexts) matchers.push({ literal });
|
|
29
33
|
const payloadText = JSON.stringify(payload);
|
|
30
34
|
let matches = 0;
|
|
31
35
|
let nodes = 0;
|
|
@@ -57,7 +61,7 @@ try {
|
|
|
57
61
|
while ((start = text.indexOf(matcher.literal, start)) !== -1) {
|
|
58
62
|
spans.push([start, start + matcher.literal.length]);
|
|
59
63
|
if (++matches > 100000) throw new Error();
|
|
60
|
-
start++; // Include overlapping occurrences.
|
|
64
|
+
start++; // Include overlapping occurrences. / 包含重叠匹配。
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
}
|
|
@@ -78,6 +82,7 @@ try {
|
|
|
78
82
|
}
|
|
79
83
|
function redact(text) {
|
|
80
84
|
// Only OUR tokens are exempt, not arbitrary strings with a similar prefix.
|
|
85
|
+
// 仅豁免本实例生成的占位符,不豁免具有相似前缀的任意字符串。
|
|
81
86
|
let result = "",
|
|
82
87
|
cursor = 0;
|
|
83
88
|
for (const match of text.matchAll(/__PIP_[a-f0-9]{48}__/g)) {
|
|
@@ -90,22 +95,25 @@ try {
|
|
|
90
95
|
function walk(value, depth = 0) {
|
|
91
96
|
if (++nodes > 200000 || depth > 80) throw new Error();
|
|
92
97
|
if (typeof value === "string") {
|
|
93
|
-
if (
|
|
98
|
+
if (protectedTexts.some(text => value.includes(text)))
|
|
94
99
|
return redact(value);
|
|
95
100
|
// OpenAI serializes tool arguments as JSON strings: decode escapes BEFORE matching.
|
|
101
|
+
// OpenAI 将工具参数序列化为 JSON 字符串,匹配前必须先解码转义。
|
|
96
102
|
if (/^\s*[[{]/.test(value)) {
|
|
97
103
|
let parsed;
|
|
98
104
|
try {
|
|
99
105
|
parsed = JSON.parse(value);
|
|
100
106
|
} catch {
|
|
101
|
-
/*
|
|
107
|
+
/* Ordinary prose. / 普通文本。 */
|
|
102
108
|
}
|
|
103
109
|
if (parsed && typeof parsed === "object") {
|
|
104
110
|
const inner = JSON.stringify(walk(parsed, depth + 1));
|
|
105
111
|
const outer = redact(inner);
|
|
106
112
|
if (outer === inner) return inner;
|
|
107
113
|
// A rule may span JSON syntax or match a whole JSON-shaped secret.
|
|
114
|
+
// 规则可能跨越 JSON 语法,或匹配整个 JSON 形式的敏感值。
|
|
108
115
|
// Return altered JSON only if it still parses; otherwise reject the request.
|
|
116
|
+
// 仅返回仍可解析的修改后 JSON,否则拒绝请求。
|
|
109
117
|
JSON.parse(outer);
|
|
110
118
|
return outer;
|
|
111
119
|
}
|
|
@@ -119,6 +127,7 @@ try {
|
|
|
119
127
|
if (Array.isArray(value)) return value.map((item) => walk(item, depth + 1));
|
|
120
128
|
if (value && typeof value === "object") {
|
|
121
129
|
// No claim to perform OCR or redact opaque attachments. Reject rather than leak.
|
|
130
|
+
// 不提供 OCR 或不透明附件脱敏,宁可拒绝请求也不放行泄漏。
|
|
122
131
|
if (
|
|
123
132
|
typeof value.type === "string" &&
|
|
124
133
|
/image|audio|video|document|(^|_)file($|_)/i.test(value.type)
|
|
@@ -146,5 +155,6 @@ try {
|
|
|
146
155
|
parentPort.postMessage({ payload: walk(payload), additions });
|
|
147
156
|
} catch {
|
|
148
157
|
// Never serialize an exception, original payload or configuration back into diagnostics.
|
|
158
|
+
// 不将异常、原始请求体或配置序列化到诊断信息中。
|
|
149
159
|
parentPort.postMessage({ failed: true });
|
|
150
160
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
for (const config of workerData) {
|
|
5
|
+
for (const rule of config.sensitiveWords) {
|
|
6
|
+
if (typeof rule === "object" && rule.type === "regex" && new RegExp(rule.pattern, rule.flags ?? "").test("")) throw new Error();
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
parentPort.postMessage(true);
|
|
10
|
+
} catch {
|
|
11
|
+
// Return no rules or parser errors. / 不返回规则或解析错误。
|
|
12
|
+
parentPort.postMessage(false);
|
|
13
|
+
}
|