pi-information-protecter 0.2.0 → 0.2.1

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog / 更新日志
2
2
 
3
+ ## 0.2.1 — Compiled distribution / 编译产物发布
4
+
5
+ - Publish compiled, bundled and minified ESM in `dist/`, including both workers; exclude source and source maps from npm packages.
6
+ 发布 `dist/` 中编译合并压缩后的 ESM 及两个 worker,npm 包不再包含源码或 source map。
7
+ - Build automatically before tests and packing; validate the actual tarball through Pi's loader and worker execution.
8
+ 测试及打包前自动构建,使用 Pi 加载器和 worker 执行验证真实压缩包。
9
+ - Separate README into full English and Simplified Chinese sections with top language links.
10
+ README 改为完整英文在前、简体中文在后,顶部提供语言跳转链接。
11
+
3
12
  ## 0.2.0 — Machine configuration and memory cache / 机器配置与内存缓存
4
13
 
5
14
  - Derive deterministic filenames from OS machine identifiers using a product-specific hash; never fall back to random IDs.
@@ -13,7 +22,6 @@
13
22
  - Add migration, failure-injection, concurrency and cache regression tests, plus bilingual migration documentation.
14
23
  增加迁移、故障注入、并发及缓存回归测试和双语迁移文档。
15
24
 
16
-
17
25
  - Add tag-triggered GitHub Actions npm OIDC publishing, scoped environment permissions and manual dry-run validation.
18
26
  增加标签触发的 GitHub Actions npm OIDC 发布、限定环境权限及手动试运行验证。
19
27
  - Document trusted publisher setup and the automated release procedure in both languages.
package/README.md CHANGED
@@ -1,63 +1,51 @@
1
1
  # pi-information-protecter
2
2
 
3
- **Sensitive Personal Information (SPI) Protecter — 个人敏感信息保护插件。**
3
+ [English](#english) · [简体中文](#简体中文)
4
4
 
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.**
5
+ ## English
6
6
 
7
- 这是一个 Pi 扩展:在本地审查 LLM 出站请求体,用随机占位符替换配置中的敏感值,并在本地还原模型返回的占位符。适用于 URL、电话号码、身份证/ID、姓名、凭证和密码。**这是基于规则的意外泄漏防护,不是沙箱、自动 PII 检测器或加密代理。**
7
+ **Sensitive Personal Information (SPI) Protecter.**
8
+
9
+ 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.**
8
10
 
9
- ## Installation / 安装
11
+ ## Installation
10
12
 
11
13
  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
14
 
13
- 要求 Node.js 22+ 和 `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**,验证基线为 0.85.1。不支持旧 `@mariozechner/*` 版本。
14
-
15
15
  ```bash
16
- # Install the published, pinned npm release. / 安装已发布的固定 npm 版本。
17
- pi install npm:pi-information-protecter@0.2.0
16
+ # Install the pinned npm release.
17
+ pi install npm:pi-information-protecter@0.2.1
18
18
 
19
- # Alternatively, install from this repository without copying it. / 或从本仓库本地安装,不复制仓库。
19
+ # Alternatively, build and install from this repository without copying it.
20
+ npm ci
21
+ npm run build
20
22
  pi install "$PWD"
21
23
 
22
- # Load for one invocation. / 单次加载。
24
+ # Load for one invocation.
23
25
  pi -e ./src/index.ts
24
26
  ```
25
27
 
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`。
28
+ Run `/reload` in an existing Pi session. Starting with `0.2.1`, npm releases ship compiled, minified ESM in `dist/`, not TypeScript source. Keep all three files together: `index.mjs`, `scan-worker.mjs` and `validate-worker.mjs`. Version `0.2.0` uses the previous source layout.
29
29
 
30
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
31
 
32
- 版本变更见 [CHANGELOG.md](CHANGELOG.md)。贡献与发布流程见仓库内的 `CONTRIBUTING.md` 和 `docs/RELEASING.md`。
33
-
34
32
  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
33
 
36
- 维护者通过推送匹配版本的 `v*` 标签发布新版本。GitHub Actions 验证后使用 npm OIDC 可信发布,不需要长期令牌;手动试运行不会发布。
37
-
38
34
  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
35
 
40
- 初始化使用 `~/.pi/agent/protecter.<machineHash>.json`,遵循 `PI_CODING_AGENT_DIR`。哈希由产品前缀与规范化系统标识计算 SHA-256,取前 32 位十六进制;macOS 使用 `IOPlatformUUID`、Linux 使用 `/etc/machine-id`、Windows 使用 `MachineGuid`。标识缺失或无效时拒绝初始化,不随机回退。哈希是稳定标识而非秘密,不加载项目级配置。
41
-
42
36
  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
37
 
44
- 启动和重载时,识别到的旧配置(包括原固定文件名)会先验证,再合并去重到当前目标;目标持久化写入并验证后才删除未变化的旧文件。无效来源会中止迁移而不删除原文件。Unix 权限为 `0600`,拒绝不安全链接、他人所有文件和超限文件;Windows 需自行设置 ACL。详见迁移文档。
45
-
46
38
  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.
47
39
 
48
- 验证后的配置按扩展实例缓存。出站扫描及状态命令不重新读取或检查配置文件;加载后修改、损坏或删除文件不改变当前快照。修改在重载后生效;`/protecter reload` 会调用 Pi 重载,会话切换产生的新实例也初始化一次。若全部配置已删除,下次初始化会创建空配置并警告,旧内存不跨实例销毁保留。
49
-
50
- ## Configuration / 配置
40
+ ## Configuration
51
41
 
52
42
  **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
43
 
54
- **请用本地编辑器修改配置,不要让模型填写真实密码,也不要把真实配置提交到 Git。** 初始规则数组为空,不具备个人信息检测能力,界面会给出警告。插件不会自动识别所有 SPI。
55
-
56
44
  ```json
57
45
  {
58
46
  "version": 1,
59
47
  "sensitiveWords": [
60
- "张三",
48
+ "Alex Morgan",
61
49
  { "type": "literal", "value": "your-private-password" },
62
50
  { "type": "regex", "pattern": "https?://[^\\s\\\"<>]+", "flags": "i" },
63
51
  { "type": "regex", "pattern": "(?<!\\d)1[3-9]\\d{9}(?!\\d)" }
@@ -67,84 +55,55 @@ Validated configuration is cached per extension instance. Outgoing scans and sta
67
55
 
68
56
  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
57
 
70
- 更多示例见 [`protecter.example.json`](protecter.example.json),不会自动启用。号码规则不验证真实性或校验位,姓名和密码建议使用精确值。
71
-
72
58
  - **Literals:** strings and `literal` rules match all case-sensitive occurrences, without regex interpretation.
73
- **字面规则:**字符串和 `literal` 按大小写敏感方式匹配全部出现位置,不作正则解释。
74
59
  - **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
60
  - Escape backslashes as `\\` in JSON. Empty-string matches are invalid; runtime zero-width matches reject the request.
77
- JSON 中的反斜杠写作 `\\`。不允许匹配空字符串;运行时零宽匹配会拒绝请求。
78
61
  - Overlapping matches are merged to avoid exposing a longer secret's suffix. Equal values reuse a token within the extension instance.
79
- 合并重叠匹配,避免暴露长敏感值的后缀;同一实例内相同原文复用占位符。
80
62
  - 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
63
  - `/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
64
  - 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;超限拒绝,不降级发送明文。
86
65
 
87
- ## How it works / 工作方式
66
+ ## How it works
88
67
 
89
68
  ```text
90
69
  Input / history / tools / system prompt / tool definitions
91
- 输入/历史/工具结果/系统提示/工具定义
92
70
  ↓
93
- before_provider_request: final JSON text scan / 最终 JSON 文本扫描
71
+ before_provider_request: final JSON text scan
94
72
  ↓
95
- __PIP_<192-bit random hex>__ → LLM gateway / LLM 网关
73
+ __PIP_<192-bit random hex>__ → LLM gateway
96
74
  ↓
97
- Local response and tool-argument restoration / 本地回复和工具参数还原
75
+ Local response and tool-argument restoration
98
76
  ```
99
77
 
100
78
  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
79
  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
80
  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
81
  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
82
  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
83
 
111
- ### Configuration access guard / 配置访问防护
84
+ ### Configuration access guard
112
85
 
113
86
  **Outgoing redaction is the main defense; direct-access blocking is supplementary.** Shell is not disabled wholesale.
114
87
 
115
- **以出站脱敏为主,直接访问拦截为辅。** 不全面禁用 shell。
116
-
117
88
  - 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
89
  - Resolve `@`, `~`, relative paths and `file://`; check symlink/inode aliases. Block `grep/find/ls` over ancestor directories containing the configuration.
120
- 解析上述路径形式,检查符号链接和 inode 别名;阻止这些搜索工具扫描包含配置的祖先目录。
121
90
  - Attempt whole-text masking if the complete original configuration accidentally appears in a request.
122
- 完整原始配置意外出现在请求文本时,尝试整体替换。
123
91
  - **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
92
 
126
- ## Limitations — read first / 当前限制——请先阅读
93
+ ## Limitations — read first
127
94
 
128
95
  - 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
96
  - `/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
97
  - 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
98
  - 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
99
  - Reused tokens reveal equality relationships; context may imply identity. This is not formal anonymization.
137
- 复用占位符会暴露值相等的关系,上下文也可能揭示身份,这不是形式化匿名化。
138
100
  - 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
101
  - **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
102
  - Local sessions, terminals, exports and files written by tools may contain plaintext. They are not encrypted or cleaned by this project.
143
- 本地会话、终端、导出和工具写出的文件可能包含明文,本项目不加密或清理它们。
144
103
 
145
- See [SECURITY.md](SECURITY.md) for the threat model. / 详细威胁模型见该安全文档。
104
+ See [SECURITY.md](SECURITY.md) for the threat model.
146
105
 
147
- ## Development and verification / 开发与验证
106
+ ## Development and verification
148
107
 
149
108
  ```bash
150
109
  npm ci
@@ -153,8 +112,122 @@ npm run check
153
112
 
154
113
  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
114
 
156
- 测试仅使用临时目录和虚构敏感值,不读取真实配置、不使用真实密钥或访问 LLM 网关,覆盖脱敏还原、正则超时、权限、路径防护和真实 Pi 加载器/执行器集成。所有 Markdown、注释和新提交信息必须使用英文+简体中文。
115
+ `npm run build` bundles local modules with esbuild, removes TypeScript types and minifies output. Node built-ins and Pi peer packages remain external. No source maps or source files are published; source remains available on GitHub. Minification reduces size, not visibility or security. `npm pack` rebuilds via `prepack`; `npm publish` first runs checks. Tests also extract the actual npm tarball and run Pi's loader and both workers from it. See the repository's `docs/BUILDING.md` for details.
157
116
 
158
117
  API references: [Pi Extensions](https://pi.dev/docs/latest/extensions), local 0.85.1 documentation and runner implementation. Licensed under MIT.
159
118
 
160
- 接口依据为 Pi 官方扩展文档、本机 0.85.1 文档及执行器实现;采用 MIT 许可证。
119
+ ---
120
+
121
+ ## 简体中文
122
+
123
+ **个人敏感信息保护插件。**
124
+
125
+ 这是一个 Pi 扩展:在本地审查 LLM 出站请求体,用随机占位符替换配置中的敏感值,并在本地还原模型返回的占位符。适用于 URL、电话号码、身份证/ID、姓名、凭证和密码。**这是基于规则的意外泄漏防护,不是沙箱、自动 PII 检测器或加密代理。**
126
+
127
+ ## 安装
128
+
129
+ 要求 Node.js 22+ 和 `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**,验证基线为 0.85.1。不支持旧 `@mariozechner/*` 版本。
130
+
131
+ ```bash
132
+ # 安装固定 npm 版本。
133
+ pi install npm:pi-information-protecter@0.2.1
134
+
135
+ # 或在本仓库构建后本地安装,不复制仓库。
136
+ npm ci
137
+ npm run build
138
+ pi install "$PWD"
139
+
140
+ # 单次加载。
141
+ pi -e ./src/index.ts
142
+ ```
143
+
144
+ 已运行的 Pi 请执行 `/reload`。从 `0.2.1` 起,npm 发布 `dist/` 中编译压缩后的 ESM,而非 TypeScript 源码。必须同时保留 `index.mjs`、`scan-worker.mjs` 和 `validate-worker.mjs` 三个文件。`0.2.0` 采用旧源码布局。
145
+
146
+ 版本变更见 [CHANGELOG.md](CHANGELOG.md)。贡献与发布流程见仓库内的 `CONTRIBUTING.md` 和 `docs/RELEASING.md`。
147
+
148
+ 维护者通过推送匹配版本的 `v*` 标签发布新版本。GitHub Actions 验证后使用 npm OIDC 可信发布,不需要长期令牌;手动试运行不会发布。
149
+
150
+ 初始化使用 `~/.pi/agent/protecter.<machineHash>.json`,遵循 `PI_CODING_AGENT_DIR`。哈希由产品前缀与规范化系统标识计算 SHA-256,取前 32 位十六进制;macOS 使用 `IOPlatformUUID`、Linux 使用 `/etc/machine-id`、Windows 使用 `MachineGuid`。标识缺失或无效时拒绝初始化,不随机回退。哈希是稳定标识而非秘密,不加载项目级配置。
151
+
152
+ 启动和重载时,识别到的旧配置(包括原固定文件名)会先验证,再合并去重到当前目标;目标持久化写入并验证后才删除未变化的旧文件。无效来源会中止迁移而不删除原文件。Unix 权限为 `0600`,拒绝不安全链接、他人所有文件和超限文件;Windows 需自行设置 ACL。详见[迁移文档](docs/MIGRATION.md)。
153
+
154
+ 验证后的配置按扩展实例缓存。出站扫描及状态命令不重新读取或检查配置文件;加载后修改、损坏或删除文件不改变当前快照。修改在重载后生效;`/protecter reload` 会调用 Pi 重载,会话切换产生的新实例也初始化一次。若全部配置已删除,下次初始化会创建空配置并警告,旧内存不跨实例销毁保留。
155
+
156
+ ## 配置
157
+
158
+ **请用本地编辑器修改配置,不要让模型填写真实密码,也不要把真实配置提交到 Git。** 初始规则数组为空,不具备个人信息检测能力,界面会给出警告。插件不会自动识别所有 SPI。
159
+
160
+ ```json
161
+ {
162
+ "version": 1,
163
+ "sensitiveWords": [
164
+ "张三",
165
+ { "type": "literal", "value": "your-private-password" },
166
+ { "type": "regex", "pattern": "https?://[^\\s\\\"<>]+", "flags": "i" },
167
+ { "type": "regex", "pattern": "(?<!\\d)1[3-9]\\d{9}(?!\\d)" }
168
+ ]
169
+ }
170
+ ```
171
+
172
+ 更多示例见 [`protecter.example.json`](protecter.example.json),不会自动启用。号码规则不验证真实性或校验位,姓名和密码建议使用精确值。
173
+
174
+ - **字面规则:**字符串和 `literal` 按大小写敏感方式匹配全部出现位置,不作正则解释。
175
+ - **正则:**JavaScript 正则替换整个匹配,可用后行断言仅匹配凭证值。支持 `g i m s u`,自动补 `g`,不支持 `/pattern/flags` 简写。
176
+ - JSON 中的反斜杠写作 `\\`。不允许匹配空字符串;运行时零宽匹配会拒绝请求。
177
+ - 合并重叠匹配,避免暴露长敏感值的后缀;同一实例内相同原文复用占位符。
178
+ - 已识别原文在当前实例内持续受到精确匹配保护,即使正则上下文消失或规则被删除;退出或重载清除记录。
179
+ - 状态命令只报告缓存数量,不访问磁盘或显示敏感值。重载命令等待空闲并调用 Pi 完整重载,不再只是查询状态;不支持通过命令参数添加秘密。
180
+ - 限制为 1000 条规则、每个词或模式 8192 字符、扫描 2 秒、请求 JSON 8 MiB;超限拒绝,不降级发送明文。
181
+
182
+ ## 工作方式
183
+
184
+ ```text
185
+ 输入/历史/工具结果/系统提示/工具定义
186
+ ↓
187
+ before_provider_request:最终 JSON 文本扫描
188
+ ↓
189
+ __PIP_<192-bit 随机十六进制>__ → LLM 网关
190
+ ↓
191
+ 本地回复和工具参数还原
192
+ ```
193
+
194
+ 1. 扫描 JSON 字符串、键和数字,包括解码后的 JSON 字符串工具参数;不修改本地用户输入和历史原文。
195
+ 2. 使用 `crypto.randomBytes(24)` 生成占位符。映射**仅存于内存**,不写入配置、会话自定义条目、日志或模型提示;本地用户仍可看到原文。
196
+ 3. 还原最终 assistant 文本、思考内容和工具参数。TUI 在完整占位符到达后还原,片段可能短暂显示;RPC/JSON 增量仍脱敏,直到最终消息还原。被改写或截断的占位符无法还原。
197
+ 4. 工具执行前还原参数,让合法本地操作使用原值;后续请求再次脱敏。**凭证还原不等于工具授权或防外传。**
198
+ 5. 正则扫描运行于可终止 worker。失败时返回 `{}` 并请求取消,不依赖被 Pi 吞掉的钩子异常;仍可能产生空请求或提供商校验错误,但处理器不返回原始请求体。
199
+
200
+ ### 配置访问防护
201
+
202
+ **以出站脱敏为主,直接访问拦截为辅。** 不全面禁用 shell。
203
+
204
+ - 文件工具检查实际路径参数而非正文,允许文档提及配置名称。当前配置目录内的旧名称、哈希名称及迁移文件受保护;shell 命令字段保留尽力文件名检查,因此 shell 提及名称仍可能误拦截。
205
+ - 解析 `@`、`~`、相对路径和 `file://`,检查符号链接和 inode 别名;阻止 `grep/find/ls` 扫描包含配置的祖先目录。
206
+ - 完整原始配置意外出现在请求文本时,尝试整体替换。
207
+ - **仅为尽力防护:**动态 shell、编码、拆分输出、目录内链接、任意第三方工具和网络发送可绕过检查,无法保证恶意模型永远无法取得配置。
208
+
209
+ ## 当前限制——请先阅读
210
+
211
+ - 仅保护命中规则的文本,不覆盖改写、拆字和任意编码。常见多模态附件因无法可靠审查而拒绝整个请求,不提供 OCR。
212
+ - 暂时取消压缩和带摘要的树导航,等待单独验证生命周期及还原;不带摘要的导航可用,长会话请使用 `/new`。
213
+ - 退出、重载和切换会话会丢弃映射。最终已还原消息仍可读,但崩溃残留、旧摘要和原始增量无法跨重启恢复占位符。
214
+ - 过宽规则可能改写协议字段、模型名、工具结构和 ID,导致请求失败;命中数字变为占位符字符串,应优先使用精确规则。
215
+ - 复用占位符会暴露值相等的关系,上下文也可能揭示身份,这不是形式化匿名化。
216
+ - 钩子不覆盖认证头、API key、网关地址、遥测、分享、扩展独立网络调用和工具联网;网关仍收到其认证凭证。
217
+ - **在其他请求改写扩展之后加载,并只使用可信扩展。** 后续处理器可重新加入原文;扩展有本机权限,插件加载失败时 Pi 可能继续运行,请先检查状态再输入秘密。
218
+ - 本地会话、终端、导出和工具写出的文件可能包含明文,本项目不加密或清理它们。
219
+
220
+ 详细威胁模型见 [SECURITY.md](SECURITY.md)。
221
+
222
+ ## 开发与验证
223
+
224
+ ```bash
225
+ npm ci
226
+ npm run check
227
+ ```
228
+
229
+ 测试仅使用临时目录和虚构敏感值,不读取真实配置、不使用真实密钥或访问 LLM 网关,覆盖脱敏还原、正则超时、权限、路径防护和真实 Pi 加载器/执行器集成。所有 Markdown、注释和新提交信息必须使用英文+简体中文。
230
+
231
+ `npm run build` 使用 esbuild 合并本地模块、移除类型并压缩产物,Node 内置模块和 Pi peer 依赖保持外部引用。不发布源码或 source map,源码仍可在 GitHub 查阅。压缩是体积优化,不是加密或安全隔离。`npm pack` 通过 `prepack` 自动构建,`npm publish` 先执行检查。测试还会解压实际 npm 压缩包,用 Pi 加载器和两个 worker 验证产物。详见仓库内的 `docs/BUILDING.md`。
232
+
233
+ 接口依据为 [Pi 官方扩展文档](https://pi.dev/docs/latest/extensions)、本机 0.85.1 文档及执行器实现;采用 MIT 许可证。
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
2
+ import{getAgentDir as Se,VERSION as q}from"@earendil-works/pi-coding-agent";import{Worker as ue}from"node:worker_threads";import{fileURLToPath as le}from"node:url";import*as a from"node:fs";import{join as S,resolve as re}from"node:path";import{createHash as te,randomUUID as ne}from"node:crypto";import{Worker as oe}from"node:worker_threads";import{fileURLToPath as ie}from"node:url";import{constants as y,closeSync as M,fchmodSync as Y,fstatSync as K,mkdirSync as Q,openSync as A,readFileSync as V,writeFileSync as Z}from"node:fs";import{dirname as ee}from"node:path";var C={version:1,sensitiveWords:[]},u="SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";function R(i){try{let e=JSON.parse(i);if(!e||e.version!==1||!Array.isArray(e.sensitiveWords)||e.sensitiveWords.length>1e3||Object.keys(e).some(r=>!["version","sensitiveWords"].includes(r)))throw new Error;for(let r of e.sensitiveWords){if(typeof r=="string"){if(!r||r.length>8192)throw new Error;continue}if(!r||typeof r!="object")throw new Error;if(r.type==="literal"){if(Object.keys(r).some(t=>!["type","value"].includes(t))||typeof r.value!="string"||!r.value||r.value.length>8192)throw new Error}else if(r.type==="regex"){if(Object.keys(r).some(o=>!["type","pattern","flags"].includes(o))||typeof r.pattern!="string"||!r.pattern||r.pattern.length>8192||r.flags!==void 0&&(typeof r.flags!="string"||!/^[gimsu]*$/.test(r.flags)))throw new Error;let t=new RegExp(r.pattern,r.flags??"")}else throw new Error}return e}catch{throw new Error(u)}}function W(i,e=!1){let r;try{if(e){Q(ee(i),{recursive:!0,mode:448});try{let n=A(i,y.O_WRONLY|y.O_CREAT|y.O_EXCL,384);try{Z(n,JSON.stringify(C,null,2)+`
3
+ `)}finally{M(n)}}catch(n){if(n.code!=="EEXIST")throw n}}r=A(i,y.O_RDONLY|y.O_NOFOLLOW|y.O_NONBLOCK);let t=K(r);if(!t.isFile()||t.nlink!==1||t.size>1024*1024||process.getuid&&t.uid!==process.getuid())throw new Error;process.platform!=="win32"&&Y(r,384);let o=V(r,"utf8");if(Buffer.byteLength(o)>1024*1024)throw new Error;return{config:R(o),raw:o}}catch{throw new Error(u)}finally{r!==void 0&&M(r)}}var b=/^protecter(?:\.[a-f0-9]{32})?\.json$/;function se(i){return new Promise((e,r)=>{let t=new oe(ie(new URL("./validate-worker.mjs",import.meta.url)),{workerData:i,execArgv:[],resourceLimits:{maxOldGenerationSizeMb:64}}),o=!1,n=l=>{o||(o=!0,clearTimeout(s),t.terminate(),l?e():r(new Error(u)))},s=setTimeout(()=>n(!1),2e3);t.once("message",l=>n(l===!0)),t.once("error",()=>n(!1)),t.once("exit",()=>n(!1))})}function ae(i){let e=new Set,r=[];for(let o of i)for(let n of o.sensitiveWords){let s=typeof n=="string"?JSON.stringify(["literal",n]):n.type==="literal"?JSON.stringify(["literal",n.value]):JSON.stringify(["regex",n.pattern,[...new Set((n.flags??"")+"g")].sort().join("")]);e.has(s)||(e.add(s),r.push(n))}let t=JSON.stringify({version:1,sensitiveWords:r});if(Buffer.byteLength(t)>1024*1024)throw new Error(u);return R(t)}function F(i){let e=a.lstatSync(i,{bigint:!0});if(!e.isFile()||e.nlink!==1n||process.getuid&&e.uid!==BigInt(process.getuid()))throw new Error(u);return`${e.dev}:${e.ino}:${e.size}:${e.mtimeNs}`}function P(i,e){let r=S(i,e),t=F(r),o=W(r);if(F(r)!==t)throw new Error(u);return{name:e,...o,identity:t,digest:te("sha256").update(o.raw).digest("hex")}}function O(i,e){let r=P(i,e.name);if(r.identity!==e.identity||r.digest!==e.digest)throw new Error(u)}function k(i){return a.readdirSync(i).filter(e=>b.test(e)).sort()}function D(i){if(process.platform==="win32")return;let e=a.openSync(i,a.constants.O_RDONLY);try{a.fsyncSync(e)}finally{a.closeSync(e)}}async function j(i,e,r={}){if(!/^[a-f0-9]{32}$/.test(e))throw new Error(u);let t=re(i),o=`protecter.${e}.json`,n=S(t,o);a.mkdirSync(t,{recursive:!0,mode:448});let s=S(t,".protecter-migration.lock"),l=Date.now()+(r.lockTimeoutMs??5e3);for(;;)try{a.mkdirSync(s,{mode:448});break}catch(m){if(m.code!=="EEXIST"||Date.now()>=l)throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");await new Promise(h=>setTimeout(h,50))}let g;try{let m=k(t);if(m.length>64)throw new Error(u);let f=[...m].sort((c,L)=>c===o?-1:L===o?1:c.localeCompare(L)).map(c=>P(t,c)),d=ae(f.length?f.map(c=>c.config):[C]);if(await se(f.length?f.map(c=>c.config):[d]),f.length===1&&f[0].name===o){if(O(t,f[0]),JSON.stringify(k(t))!==JSON.stringify(m))throw new Error(u);return{path:n,config:f[0].config,raw:f[0].raw,sourceRaws:[f[0].raw],migrated:0}}let w=JSON.stringify(d,null,2)+`
4
+ `;if(Buffer.byteLength(w)>1024*1024)throw new Error(u);g=S(t,`.protecter-migration.${ne()}.tmp`);let E=a.openSync(g,a.constants.O_WRONLY|a.constants.O_CREAT|a.constants.O_EXCL,384);try{a.writeFileSync(E,w),a.fsyncSync(E)}finally{a.closeSync(E)}if(r.checkpoint?.("beforeCommit",n),JSON.stringify(k(t))!==JSON.stringify(m))throw new Error(u);for(let c of f)O(t,c);a.renameSync(g,n),g=void 0,D(t);let x=P(t,o);if(x.raw!==w)throw new Error(u);r.checkpoint?.("afterCommit",n);for(let c of f)c.name!==o&&(r.checkpoint?.("beforeDelete",S(t,c.name)),O(t,x),O(t,c),a.unlinkSync(S(t,c.name)));D(t);let X=[o];if(JSON.stringify(k(t))!==JSON.stringify(X))throw new Error(u);return O(t,x),{path:n,config:d,raw:w,sourceRaws:[...new Set([...f.map(c=>c.raw),w])],migrated:f.filter(c=>c.name!==o).length}}catch{throw new Error(u)}finally{if(g)try{a.unlinkSync(g)}catch{}a.rmdirSync(s)}}import{createHash as ce}from"node:crypto";import{readFileSync as fe}from"node:fs";import{execFileSync as J}from"node:child_process";var $="SPI Protecter: machine ID unavailable / 无法获取机器标识。";function N(i){let e=i.trim().toLowerCase().replace(/-/g,"");if(!/^[a-f0-9]{32}$/.test(e)||/^0+$/.test(e))throw new Error($);return ce("sha256").update(`pi-information-protecter:v1:${e}`).digest("hex").slice(0,32)}function U(){try{if(process.platform==="linux")return N(fe("/etc/machine-id","utf8"));if(process.platform==="darwin"){let i=J("/usr/sbin/ioreg",["-rd1","-c","IOPlatformExpertDevice"],{encoding:"utf8",timeout:5e3,maxBuffer:1048576,stdio:["ignore","pipe","pipe"]});return N(i.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1]??"")}if(process.platform==="win32"){let i=J("reg.exe",["query","HKLM\\SOFTWARE\\Microsoft\\Cryptography","/v","MachineGuid","/reg:64"],{encoding:"utf8",timeout:5e3,maxBuffer:65536,stdio:["ignore","pipe","pipe"]});return N(i.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1]??"")}}catch{}throw new Error($)}var p="protecter: 请求已清空。配置无效、扫描超时或包含不支持的附件;修复后重试。",v=class{constructor(e,r=2e3,t=U){this.configDir=e;this.timeoutMs=r;this.machineId=t}configDir;timeoutMs;machineId;tokens=new Map;queue=Promise.resolve();workers=new Set;closed=!1;snapshot;async initialize(){if(this.closed)throw new Error(p);this.snapshot=void 0;let e=await j(this.configDir,this.machineId());if(this.closed)throw new Error(p);this.snapshot=e}get configPath(){return this.snapshot?.path??""}get ruleCount(){return this.snapshot?.config.sensitiveWords.length??0}get ready(){return!!this.snapshot&&!this.closed}get mappingCount(){return this.tokens.size}redact(e){let r=this.queue.then(()=>this.scan(e));return this.queue=r.catch(()=>{}),r}async scan(e){if(!this.ready)throw new Error(p);let{config:r,sourceRaws:t}=this.snapshot,o=JSON.stringify(e);if(!o||Buffer.byteLength(o)>8*1024*1024)throw new Error(p);let n;try{n=JSON.parse(o)}catch{throw new Error(p)}return new Promise((s,l)=>{let g=!1,m=new ue(le(new URL("./scan-worker.mjs",import.meta.url)),{workerData:{payload:n,rules:r.sensitiveWords,entries:[...this.tokens],configRaws:t},execArgv:[],resourceLimits:{maxOldGenerationSizeMb:128}});this.workers.add(m);let h=d=>{if(!g){if(g=!0,clearTimeout(f),this.workers.delete(m),m.terminate(),!d||this.closed){l(new Error(p));return}for(let[w,E]of d.additions)this.tokens.set(w,E);s(d.payload)}},f=setTimeout(()=>h(),this.timeoutMs);m.once("message",d=>h(d.failed?void 0:d)),m.once("error",()=>h()),m.once("exit",()=>h())})}restoreText(e){return e.replace(/__PIP_[a-f0-9]{48}__/g,r=>this.tokens.get(r)??r)}restore(e,r=0){if(r>80)throw new Error("protecter: 响应层级过深。");if(typeof e=="string")return this.restoreText(e);if(Array.isArray(e))return e.map(t=>this.restore(t,r+1));if(e&&typeof e=="object"){let t=Object.create(null);for(let[o,n]of Object.entries(e)){let s=this.restoreText(o);if(Object.hasOwn(t,s))throw new Error("protecter: 响应键冲突。");t[s]=this.restore(n,r+1)}return t}return e}close(){this.closed=!0,this.snapshot=void 0,this.tokens.clear();for(let e of this.workers)e.terminate();this.workers.clear()}};import{realpathSync as ge,statSync as G}from"node:fs";import{homedir as me}from"node:os";import{basename as z,dirname as _,isAbsolute as de,relative as B,resolve as I}from"node:path";import{fileURLToPath as pe}from"node:url";function he(i,e){let r=i.replace(/^@/,"");return r.startsWith("file://")&&(r=pe(r)),(r==="~"||r.startsWith("~/"))&&(r=me()+r.slice(1)),I(e,r)}function T(i){try{return ge(i)}catch{let e=_(i);return e===i?i:I(T(e),B(e,i))}}function we(i,e){let r=B(i,e);return r===""||!r.startsWith("..")&&!de(r)}function ye(i,e){try{let r=G(i),t=G(e);return r.dev===t.dev&&r.ino===t.ino}catch{return!1}}function H(i,e,r,t){if(!t)return!0;let o=T(I(t));if(["bash","powershell"].includes(i)){let l=typeof e.command=="string"?e.command:"";if(/protecter(?:\.[a-f0-9]{32})?\.json|\.protecter-migration/i.test(l)||l.includes(t))return!0}let n=e.path??e.file_path??e.filePath,s=["grep","find","ls"].includes(i);if(typeof n!="string"&&!s)return!1;try{let l=he(typeof n=="string"?n:".",r),g=T(l);return _(g)===_(o)&&(b.test(z(g))||z(g).startsWith(".protecter-migration"))||g===o||ye(l,t)||s&&we(g,o)}catch{return!0}}function Ee(i){let e=new v(Se()),r=/^0\.85\./.test(q)&&Number(q.split(".")[2])>=1,t=!1;i.on("session_start",async(o,n)=>{try{if(!r)throw new Error;await e.initialize(),t=!0;let s=e.ruleCount;n.ui.setStatus("protecter",`SPI Protecter · ${s} rules`),s===0&&n.ui.notify("SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。","warning")}catch{t=!1,n.ui.notify(p,"error")}}),i.on("before_provider_request",async(o,n)=>{try{if(!r)throw new Error;let s=await e.redact(o.payload);return t=!0,s}catch{t=!1;try{await n.abort()}catch{}try{n.ui.notify(p,"error")}catch{}return{}}}),i.on("message_end",o=>{if(o.message.role!=="assistant")return;let n=o.message;return{message:{...n,content:n.content.map(s=>s.type==="text"?{...s,text:e.restoreText(s.text)}:s.type==="thinking"?{...s,thinking:e.restoreText(s.thinking)}:s.type==="toolCall"?{...s,arguments:e.restore(s.arguments)}:s),...n.errorMessage?{errorMessage:e.restoreText(n.errorMessage)}:{}}}}),i.registerMarkdownTransformer((o,n)=>n.messageType==="user"?o:e.restoreText(o)),i.on("tool_call",(o,n)=>{if(!t)return{block:!0,reason:"SPI Protecter 尚未就绪。"};let s=e.restore(o.input);for(let l of Object.keys(o.input))delete o.input[l];if(Object.assign(o.input,s),H(o.toolName,o.input,n.cwd,e.configPath))return{block:!0,reason:"SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。"}}),i.on("session_before_compact",(o,n)=>(n.ui.notify("SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。","warning"),{cancel:!0})),i.on("session_before_tree",o=>o.preparation.userWantsSummary?{cancel:!0}:void 0),i.registerCommand("protecter",{description:"SPI Protecter memory status / 内存状态(不显示敏感值)",handler:async(o,n)=>{if(o.trim()&&!["status","reload"].includes(o.trim())){n.ui.notify("用法:/protecter [status|reload]。不要在命令参数中填写敏感值。","info");return}if(o.trim()==="reload"){await n.waitForIdle(),await n.reload();return}n.ui.notify(`SPI Protecter: ${e.ready?"ready / 就绪":"not ready / 未就绪"}; ${e.ruleCount} rules / 规则; ${e.mappingCount} mappings / 映射. Memory snapshot; /reload to refresh / 内存快照,重载后更新。`,e.ready?"info":"error")}}),i.on("session_shutdown",()=>e.close())}export{Ee as default};
@@ -0,0 +1,2 @@
1
+ /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
2
+ import{parentPort as _,workerData as b}from"node:worker_threads";import{randomBytes as j}from"node:crypto";try{let u=function(t){let r=l.get(t);if(r)return r;if(a.size>=5e4)throw new Error;do r=`__PIP_${j(24).toString("hex")}__`;while(a.has(r)||E.includes(r));return l.set(t,r),a.set(r,t),d.push([r,t]),r},h=function(t){let r=[];for(let o of p)if(o.literal===void 0){o.regex.lastIndex=0;for(let n of t.matchAll(o.regex)){if(!n[0].length)throw new Error;if(r.push([n.index,n.index+n[0].length]),++y>1e5)throw new Error}}else{if(!o.literal)continue;let n=0;for(;(n=t.indexOf(o.literal,n))!==-1;){if(r.push([n,n+o.literal.length]),++y>1e5)throw new Error;n++}}r.sort((o,n)=>o[0]-n[0]||n[1]-o[1]);let e=[];for(let o of r){let n=e.at(-1);n&&o[0]<n[1]?n[1]=Math.max(n[1],o[1]):e.push([...o])}let s="",i=0;for(let[o,n]of e)s+=t.slice(i,o)+u(t.slice(o,n)),i=n;return s+t.slice(i)},f=function(t){let r="",e=0;for(let s of t.matchAll(/__PIP_[a-f0-9]{48}__/g))a.has(s[0])&&(r+=h(t.slice(e,s.index))+s[0],e=s.index+s[0].length);return r+h(t.slice(e))},c=function(t,r=0){if(++x>2e5||r>80)throw new Error;if(typeof t=="string"){if(m.some(e=>t.includes(e)))return f(t);if(/^\s*[[{]/.test(t)){let e;try{e=JSON.parse(t)}catch{}if(e&&typeof e=="object"){let s=JSON.stringify(c(e,r+1)),i=f(s);return i===s?s:(JSON.parse(i),i)}}return f(t)}if(typeof t=="number"){let e=f(String(t));return e===String(t)?t:e}if(Array.isArray(t))return t.map(e=>c(e,r+1));if(t&&typeof t=="object"){if(typeof t.type=="string"&&/image|audio|video|document|(^|_)file($|_)/i.test(t.type))throw new Error;if(Object.keys(t).some(s=>/^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(s)))throw new Error;let e=Object.create(null);for(let[s,i]of Object.entries(t)){let o=f(s);if(Object.hasOwn(e,o))throw new Error;e[o]=c(i,r+1)}return e}if(t===null||typeof t=="boolean")return t;throw new Error};J=u,M=h,N=f,k=c;let{payload:g,rules:O,entries:w,configRaws:S}=b,l=new Map(w.map(([t,r])=>[r,t])),a=new Map(w),d=[],p=O.map(t=>typeof t=="string"?{literal:t}:t.type==="literal"?{literal:t.value}:{regex:new RegExp(t.pattern,[...new Set((t.flags??"")+"g")].join(""))});for(let t of l.keys())p.push({literal:t});let m=[...new Set(S.flatMap(t=>[t,JSON.stringify(JSON.parse(t))]))];for(let t of m)p.push({literal:t});let E=JSON.stringify(g),y=0,x=0;_.postMessage({payload:c(g),additions:d})}catch{_.postMessage({failed:!0})}var J,M,N,k;
@@ -0,0 +1,2 @@
1
+ /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
2
+ import{parentPort as t,workerData as r}from"node:worker_threads";try{for(let o of r)for(let e of o.sensitiveWords)if(typeof e=="object"&&e.type==="regex"&&new RegExp(e.pattern,e.flags??"").test(""))throw new Error;t.postMessage(!0)}catch{t.postMessage(!1)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-information-protecter",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Sensitive Personal Information (SPI) Protecter — local reversible privacy redaction for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "redaction"
24
24
  ],
25
25
  "files": [
26
- "src",
26
+ "dist",
27
27
  "README.md",
28
28
  "SECURITY.md",
29
29
  "CHANGELOG.md",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "pi": {
35
35
  "extensions": [
36
- "./src/index.ts"
36
+ "./dist/index.mjs"
37
37
  ]
38
38
  },
39
39
  "engines": {
@@ -45,13 +45,17 @@
45
45
  "devDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "0.85.1",
47
47
  "@types/node": "^22.0.0",
48
+ "esbuild": "0.28.2",
48
49
  "tsx": "^4.20.0",
49
50
  "typescript": "^5.9.0"
50
51
  },
51
52
  "scripts": {
53
+ "build": "node scripts/build.mjs",
54
+ "pretest": "npm run build",
52
55
  "test": "tsx --test test/*.test.ts",
53
56
  "typecheck": "tsc --noEmit",
54
57
  "check": "npm run typecheck && npm test",
55
- "prepublishOnly": "npm run check"
58
+ "prepublishOnly": "npm run check",
59
+ "prepack": "npm run build"
56
60
  }
57
61
  }
package/src/config.ts DELETED
@@ -1,124 +0,0 @@
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
- "SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";
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
- // Compilation only here; matching is bounded in the worker. / 此处仅编译,匹配在限时 worker 内执行。
64
- void re;
65
- } else throw new Error();
66
- }
67
- return value;
68
- } catch {
69
- throw new Error(CONFIG_ERROR);
70
- }
71
- }
72
-
73
- /**
74
- * Reject symlinks and hard links; never print parser/OS errors containing secrets.
75
- * 拒绝符号链接和硬链接;不输出可能包含敏感值的解析器或系统错误。
76
- */
77
- export function loadConfig(
78
- path: string,
79
- create = false,
80
- ): { config: Config; raw: string } {
81
- let fd: number | undefined;
82
- try {
83
- if (create) {
84
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
85
- try {
86
- const initial = openSync(
87
- path,
88
- constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
89
- 0o600,
90
- );
91
- try {
92
- writeFileSync(
93
- initial,
94
- JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
95
- );
96
- } finally {
97
- closeSync(initial);
98
- }
99
- } catch (error) {
100
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
101
- }
102
- }
103
- fd = openSync(
104
- path,
105
- constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
106
- );
107
- const stat = fstatSync(fd);
108
- if (
109
- !stat.isFile() ||
110
- stat.nlink !== 1 ||
111
- stat.size > 1024 * 1024 ||
112
- (process.getuid && stat.uid !== process.getuid())
113
- )
114
- throw new Error();
115
- if (process.platform !== "win32") fchmodSync(fd, 0o600);
116
- const raw = readFileSync(fd, "utf8");
117
- if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error();
118
- return { config: parseConfig(raw), raw };
119
- } catch {
120
- throw new Error(CONFIG_ERROR);
121
- } finally {
122
- if (fd !== undefined) closeSync(fd);
123
- }
124
- }
package/src/engine.ts DELETED
@@ -1,130 +0,0 @@
1
- import { Worker } from "node:worker_threads";
2
- import { fileURLToPath } from "node:url";
3
- import { migrateConfig, type Snapshot } from "./migration.ts";
4
- import { getMachineHash } from "./machine.ts";
5
-
6
- export const REQUEST_ERROR =
7
- "protecter: 请求已清空。配置无效、扫描超时或包含不支持的附件;修复后重试。";
8
- export class Protecter {
9
- private readonly tokens = new Map<string, string>();
10
- private queue: Promise<unknown> = Promise.resolve();
11
- private readonly workers = new Set<Worker>();
12
- private closed = false;
13
- private snapshot?: Snapshot;
14
- constructor(
15
- private readonly configDir: string,
16
- private readonly timeoutMs = 2000,
17
- private readonly machineId: () => string = getMachineHash,
18
- ) {}
19
-
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;
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; }
30
- get mappingCount(): number {
31
- return this.tokens.size;
32
- }
33
-
34
- /**
35
- * Serialise scans so two requests cannot assign different tokens to the same value.
36
- * 串行扫描,避免两个请求为同一原文分配不同占位符。
37
- */
38
- redact(payload: unknown): Promise<unknown> {
39
- const run = this.queue.then(() => this.scan(payload));
40
- this.queue = run.catch(() => undefined);
41
- return run;
42
- }
43
-
44
- private async scan(payload: unknown): Promise<unknown> {
45
- if (!this.ready) throw new Error(REQUEST_ERROR);
46
- const { config, sourceRaws } = this.snapshot!;
47
- const serialized = JSON.stringify(payload);
48
- if (!serialized || Buffer.byteLength(serialized) > 8 * 1024 * 1024)
49
- throw new Error(REQUEST_ERROR);
50
- let snapshot: unknown;
51
- try {
52
- snapshot = JSON.parse(serialized);
53
- } catch {
54
- throw new Error(REQUEST_ERROR);
55
- }
56
- return new Promise((resolve, reject) => {
57
- let settled = false;
58
- const worker = new Worker(
59
- fileURLToPath(new URL("./scan-worker.mjs", import.meta.url)),
60
- {
61
- workerData: {
62
- payload: snapshot,
63
- rules: config.sensitiveWords,
64
- entries: [...this.tokens],
65
- configRaws: sourceRaws,
66
- },
67
- execArgv: [],
68
- resourceLimits: { maxOldGenerationSizeMb: 128 },
69
- },
70
- );
71
- this.workers.add(worker);
72
- const finish = (data?: {
73
- payload: unknown;
74
- additions: [string, string][];
75
- }) => {
76
- if (settled) return;
77
- settled = true;
78
- clearTimeout(timer);
79
- this.workers.delete(worker);
80
- void worker.terminate();
81
- if (!data || this.closed) {
82
- reject(new Error(REQUEST_ERROR));
83
- return;
84
- }
85
- for (const [token, original] of data.additions)
86
- this.tokens.set(token, original);
87
- resolve(data.payload);
88
- };
89
- const timer = setTimeout(() => finish(), this.timeoutMs);
90
- worker.once("message", (data) => finish(data.failed ? undefined : data));
91
- worker.once("error", () => finish());
92
- worker.once("exit", () => finish());
93
- });
94
- }
95
-
96
- restoreText(text: string): string {
97
- // One pass, never recursively expand text introduced by a replacement.
98
- // 仅替换一遍,不递归展开替换后引入的文本。
99
- return text.replace(
100
- /__PIP_[a-f0-9]{48}__/g,
101
- (token) => this.tokens.get(token) ?? token,
102
- );
103
- }
104
-
105
- restore<T>(value: T, depth = 0): T {
106
- if (depth > 80) throw new Error("protecter: 响应层级过深。");
107
- if (typeof value === "string") return this.restoreText(value) as T;
108
- if (Array.isArray(value))
109
- return value.map((item) => this.restore(item, depth + 1)) as T;
110
- if (value && typeof value === "object") {
111
- const result: Record<string, unknown> = Object.create(null);
112
- for (const [key, item] of Object.entries(value)) {
113
- const nextKey = this.restoreText(key);
114
- if (Object.hasOwn(result, nextKey))
115
- throw new Error("protecter: 响应键冲突。");
116
- result[nextKey] = this.restore(item, depth + 1);
117
- }
118
- return result as T;
119
- }
120
- return value;
121
- }
122
-
123
- close(): void {
124
- this.closed = true;
125
- this.snapshot = undefined;
126
- this.tokens.clear();
127
- for (const worker of this.workers) void worker.terminate();
128
- this.workers.clear();
129
- }
130
- }
package/src/guard.ts DELETED
@@ -1,73 +0,0 @@
1
- import { realpathSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
4
- import { CONFIG_NAME } from "./migration.ts";
5
- import { fileURLToPath } from "node:url";
6
-
7
- function normalized(path: string, cwd: string): string {
8
- let value = path.replace(/^@/, "");
9
- if (value.startsWith("file://")) value = fileURLToPath(value);
10
- if (value === "~" || value.startsWith("~/"))
11
- value = homedir() + value.slice(1);
12
- return resolve(cwd, value);
13
- }
14
- function canonical(path: string): string {
15
- try {
16
- return realpathSync(path);
17
- } catch {
18
- const parent = dirname(path);
19
- return parent === path
20
- ? path
21
- : resolve(canonical(parent), relative(parent, path));
22
- }
23
- }
24
- function contains(parent: string, child: string): boolean {
25
- const rel = relative(parent, child);
26
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
27
- }
28
- function sameFile(a: string, b: string): boolean {
29
- try {
30
- const x = statSync(a),
31
- y = statSync(b);
32
- return x.dev === y.dev && x.ino === y.ino;
33
- } catch {
34
- return false;
35
- }
36
- }
37
-
38
- /**
39
- * Best-effort access guard. Arbitrary shell/third-party tools are NOT sandboxed.
40
- * 尽力而为的访问防护,不对任意 shell 或第三方工具提供沙箱隔离。
41
- */
42
- export function blocksConfigAccess(
43
- tool: string,
44
- input: Record<string, unknown>,
45
- cwd: string,
46
- configPath: string,
47
- ): boolean {
48
- if (!configPath) return true;
49
- const target = canonical(resolve(configPath));
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
- }
55
- const candidate = input.path ?? input.file_path ?? input.filePath;
56
- const recursive = ["grep", "find", "ls"].includes(tool);
57
- if (typeof candidate !== "string" && !recursive) return false;
58
- try {
59
- const path = normalized(
60
- typeof candidate === "string" ? candidate : ".",
61
- cwd,
62
- );
63
- const real = canonical(path);
64
- return (
65
- (dirname(real) === dirname(target) && (CONFIG_NAME.test(basename(real)) || basename(real).startsWith(".protecter-migration"))) ||
66
- real === target ||
67
- sameFile(path, configPath) ||
68
- (recursive && contains(real, target))
69
- );
70
- } catch {
71
- return true;
72
- }
73
- }
package/src/index.ts DELETED
@@ -1,151 +0,0 @@
1
- import {
2
- getAgentDir,
3
- VERSION,
4
- type ExtensionAPI,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { Protecter, REQUEST_ERROR } from "./engine.ts";
7
- import { blocksConfigAccess } from "./guard.ts";
8
-
9
- /**
10
- * Explicitly install this after other extensions that rewrite outgoing payloads.
11
- * 请将本扩展放在其他改写出站请求体的扩展之后加载。
12
- */
13
- export default function informationProtecter(pi: ExtensionAPI): void {
14
- const engine = new Protecter(getAgentDir());
15
- const compatible =
16
- /^0\.85\./.test(VERSION) && Number(VERSION.split(".")[2]) >= 1;
17
- let healthy = false;
18
-
19
- pi.on("session_start", async (_event, ctx) => {
20
- try {
21
- if (!compatible) throw new Error();
22
- await engine.initialize();
23
- healthy = true;
24
- const count = engine.ruleCount;
25
- ctx.ui.setStatus("protecter", `SPI Protecter · ${count} rules`);
26
- if (count === 0)
27
- ctx.ui.notify(
28
- "SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。",
29
- "warning",
30
- );
31
- } catch {
32
- healthy = false;
33
- ctx.ui.notify(REQUEST_ERROR, "error");
34
- }
35
- });
36
-
37
- pi.on("before_provider_request", async (event, ctx) => {
38
- try {
39
- if (!compatible) throw new Error();
40
- const payload = await engine.redact(event.payload);
41
- healthy = true;
42
- return payload;
43
- } catch {
44
- healthy = false;
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
- // 必须返回空替代请求体;终止请求和通知仅作尽力处理。
49
- try {
50
- await ctx.abort();
51
- } catch {
52
- /* Replacement below remains mandatory. / 仍须返回下方的替代请求体。 */
53
- }
54
- try {
55
- ctx.ui.notify(REQUEST_ERROR, "error");
56
- } catch {
57
- /* Never fail open. / 不因异常放行原文。 */
58
- }
59
- // May produce a provider validation error, but contains NO original data.
60
- // 可能触发提供商校验错误,但不包含任何原始数据。
61
- return {};
62
- }
63
- });
64
-
65
- pi.on("message_end", (event) => {
66
- if (event.message.role !== "assistant") return;
67
- // Preserve IDs, signatures, usage and provider metadata; only restore user-facing content.
68
- // 保留 ID、签名、用量与提供商元数据,仅还原面向用户的内容。
69
- const message = event.message;
70
- return {
71
- message: {
72
- ...message,
73
- content: message.content.map((block) => {
74
- if (block.type === "text")
75
- return { ...block, text: engine.restoreText(block.text) };
76
- if (block.type === "thinking")
77
- return { ...block, thinking: engine.restoreText(block.thinking) };
78
- if (block.type === "toolCall")
79
- return { ...block, arguments: engine.restore(block.arguments) };
80
- return block;
81
- }),
82
- ...(message.errorMessage
83
- ? { errorMessage: engine.restoreText(message.errorMessage) }
84
- : {}),
85
- },
86
- };
87
- });
88
-
89
- pi.registerMarkdownTransformer((text, context) =>
90
- context.messageType === "user" ? text : engine.restoreText(text),
91
- );
92
-
93
- pi.on("tool_call", (event, ctx) => {
94
- if (!healthy) return { block: true, reason: "SPI Protecter 尚未就绪。" };
95
- const restored = engine.restore(event.input);
96
- for (const key of Object.keys(event.input))
97
- delete (event.input as Record<string, unknown>)[key];
98
- Object.assign(event.input, restored);
99
- if (
100
- blocksConfigAccess(
101
- event.toolName,
102
- event.input,
103
- ctx.cwd,
104
- engine.configPath,
105
- )
106
- ) {
107
- return {
108
- block: true,
109
- reason: "SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。",
110
- };
111
- }
112
- });
113
-
114
- // These auxiliary requests have different lifecycle/response restoration semantics.
115
- // 这些辅助请求具有不同的生命周期与响应还原语义。
116
- // Until separately verified, cancel instead of silently sending plaintext summaries.
117
- // 在单独验证前取消这些请求,避免静默发送明文摘要。
118
- pi.on("session_before_compact", (_event, ctx) => {
119
- ctx.ui.notify(
120
- "SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。",
121
- "warning",
122
- );
123
- return { cancel: true };
124
- });
125
- pi.on("session_before_tree", (event) =>
126
- event.preparation.userWantsSummary ? { cancel: true } : undefined,
127
- );
128
-
129
- pi.registerCommand("protecter", {
130
- description: "SPI Protecter memory status / 内存状态(不显示敏感值)",
131
- handler: async (args, ctx) => {
132
- if (args.trim() && !["status", "reload"].includes(args.trim())) {
133
- ctx.ui.notify(
134
- "用法:/protecter [status|reload]。不要在命令参数中填写敏感值。",
135
- "info",
136
- );
137
- return;
138
- }
139
- if (args.trim() === "reload") {
140
- await ctx.waitForIdle();
141
- await ctx.reload();
142
- return;
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
- );
148
- },
149
- });
150
- pi.on("session_shutdown", () => engine.close());
151
- }
package/src/machine.ts DELETED
@@ -1,28 +0,0 @@
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 DELETED
@@ -1,127 +0,0 @@
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
- }
@@ -1,160 +0,0 @@
1
- // A separate worker bounds user-supplied JavaScript regex execution (including ReDoS).
2
- // 独立 worker 限制用户正则的执行时间,包括正则拒绝服务风险。
3
- import { parentPort, workerData } from "node:worker_threads";
4
- import { randomBytes } from "node:crypto";
5
-
6
- try {
7
- const { payload, rules, entries, configRaws } = workerData;
8
- const originalToToken = new Map(
9
- entries.map(([token, original]) => [original, token]),
10
- );
11
- const tokenToOriginal = new Map(entries);
12
- const additions = [];
13
- const matchers = rules.map((rule) => {
14
- if (typeof rule === "string") return { literal: rule };
15
- if (rule.type === "literal") return { literal: rule.value };
16
- return {
17
- regex: new RegExp(
18
- rule.pattern,
19
- [...new Set((rule.flags ?? "") + "g")].join(""),
20
- ),
21
- };
22
- });
23
- // A restored value may lose its original regex context (e.g. a Bearer prefix).
24
- // 还原后的值可能失去原始正则上下文,例如 Bearer 前缀。
25
- // Keep learned originals protected for the lifetime of this extension instance.
26
- // 在当前扩展实例的整个生命周期内继续保护已识别的原文。
27
- for (const original of originalToToken.keys())
28
- matchers.push({ literal: original });
29
- // Defense in depth for an accidentally pasted full configuration (not a sandbox).
30
- // 对意外粘贴的完整配置提供纵深防护,但这不是沙箱。
31
- const protectedTexts = [...new Set(configRaws.flatMap(raw => [raw, JSON.stringify(JSON.parse(raw))]))];
32
- for (const literal of protectedTexts) matchers.push({ literal });
33
- const payloadText = JSON.stringify(payload);
34
- let matches = 0;
35
- let nodes = 0;
36
- function tokenFor(original) {
37
- let token = originalToToken.get(original);
38
- if (token) return token;
39
- if (tokenToOriginal.size >= 50000) throw new Error();
40
- do {
41
- token = `__PIP_${randomBytes(24).toString("hex")}__`;
42
- } while (tokenToOriginal.has(token) || payloadText.includes(token));
43
- originalToToken.set(original, token);
44
- tokenToOriginal.set(token, original);
45
- additions.push([token, original]);
46
- return token;
47
- }
48
- function redactPlain(text) {
49
- const spans = [];
50
- for (const matcher of matchers) {
51
- if (matcher.literal === undefined) {
52
- matcher.regex.lastIndex = 0;
53
- for (const match of text.matchAll(matcher.regex)) {
54
- if (!match[0].length) throw new Error();
55
- spans.push([match.index, match.index + match[0].length]);
56
- if (++matches > 100000) throw new Error();
57
- }
58
- } else {
59
- if (!matcher.literal) continue;
60
- let start = 0;
61
- while ((start = text.indexOf(matcher.literal, start)) !== -1) {
62
- spans.push([start, start + matcher.literal.length]);
63
- if (++matches > 100000) throw new Error();
64
- start++; // Include overlapping occurrences. / 包含重叠匹配。
65
- }
66
- }
67
- }
68
- spans.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
69
- const merged = [];
70
- for (const span of spans) {
71
- const prev = merged.at(-1);
72
- if (prev && span[0] < prev[1]) prev[1] = Math.max(prev[1], span[1]);
73
- else merged.push([...span]);
74
- }
75
- let result = "",
76
- cursor = 0;
77
- for (const [start, end] of merged) {
78
- result += text.slice(cursor, start) + tokenFor(text.slice(start, end));
79
- cursor = end;
80
- }
81
- return result + text.slice(cursor);
82
- }
83
- function redact(text) {
84
- // Only OUR tokens are exempt, not arbitrary strings with a similar prefix.
85
- // 仅豁免本实例生成的占位符,不豁免具有相似前缀的任意字符串。
86
- let result = "",
87
- cursor = 0;
88
- for (const match of text.matchAll(/__PIP_[a-f0-9]{48}__/g)) {
89
- if (!tokenToOriginal.has(match[0])) continue;
90
- result += redactPlain(text.slice(cursor, match.index)) + match[0];
91
- cursor = match.index + match[0].length;
92
- }
93
- return result + redactPlain(text.slice(cursor));
94
- }
95
- function walk(value, depth = 0) {
96
- if (++nodes > 200000 || depth > 80) throw new Error();
97
- if (typeof value === "string") {
98
- if (protectedTexts.some(text => value.includes(text)))
99
- return redact(value);
100
- // OpenAI serializes tool arguments as JSON strings: decode escapes BEFORE matching.
101
- // OpenAI 将工具参数序列化为 JSON 字符串,匹配前必须先解码转义。
102
- if (/^\s*[[{]/.test(value)) {
103
- let parsed;
104
- try {
105
- parsed = JSON.parse(value);
106
- } catch {
107
- /* Ordinary prose. / 普通文本。 */
108
- }
109
- if (parsed && typeof parsed === "object") {
110
- const inner = JSON.stringify(walk(parsed, depth + 1));
111
- const outer = redact(inner);
112
- if (outer === inner) return inner;
113
- // A rule may span JSON syntax or match a whole JSON-shaped secret.
114
- // 规则可能跨越 JSON 语法,或匹配整个 JSON 形式的敏感值。
115
- // Return altered JSON only if it still parses; otherwise reject the request.
116
- // 仅返回仍可解析的修改后 JSON,否则拒绝请求。
117
- JSON.parse(outer);
118
- return outer;
119
- }
120
- }
121
- return redact(value);
122
- }
123
- if (typeof value === "number") {
124
- const transformed = redact(String(value));
125
- return transformed === String(value) ? value : transformed;
126
- }
127
- if (Array.isArray(value)) return value.map((item) => walk(item, depth + 1));
128
- if (value && typeof value === "object") {
129
- // No claim to perform OCR or redact opaque attachments. Reject rather than leak.
130
- // 不提供 OCR 或不透明附件脱敏,宁可拒绝请求也不放行泄漏。
131
- if (
132
- typeof value.type === "string" &&
133
- /image|audio|video|document|(^|_)file($|_)/i.test(value.type)
134
- )
135
- throw new Error();
136
- if (
137
- Object.keys(value).some((key) =>
138
- /^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(
139
- key,
140
- ),
141
- )
142
- )
143
- throw new Error();
144
- const result = Object.create(null);
145
- for (const [key, item] of Object.entries(value)) {
146
- const nextKey = redact(key);
147
- if (Object.hasOwn(result, nextKey)) throw new Error();
148
- result[nextKey] = walk(item, depth + 1);
149
- }
150
- return result;
151
- }
152
- if (value === null || typeof value === "boolean") return value;
153
- throw new Error();
154
- }
155
- parentPort.postMessage({ payload: walk(payload), additions });
156
- } catch {
157
- // Never serialize an exception, original payload or configuration back into diagnostics.
158
- // 不将异常、原始请求体或配置序列化到诊断信息中。
159
- parentPort.postMessage({ failed: true });
160
- }
@@ -1,13 +0,0 @@
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
- }