pi-information-protecter 0.2.0 → 0.3.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog / 更新日志
2
2
 
3
+ ## 0.3.0 — Private redaction audit / 私有脱敏审计
4
+
5
+ - Persist successful distinct original/token pairs per request in `protecter.<machineHash>.jsonl`, with local timestamp, UTC offset, provider ID and request ID. This intentionally adds plaintext sensitive data on disk; records represent local redaction, not confirmed network delivery.
6
+ 按请求持久化唯一原文与占位符到私有 JSONL,含本地时间、时区偏移、供应商及请求 ID;明确新增磁盘敏感明文,记录表示本地脱敏而非网络已送达。
7
+ - `/protecter` and `logs [1-100]` open a bounded local TUI viewer with confirmation; keep status/reload subcommands, never inject audit records into model/session context.
8
+ 命令提供有限本地查看及确认,保留状态和重载,不将审计注入模型或会话。
9
+ - Add private permissions, unsafe-link rejection, cooperative write locks, fsync, log limits and fail-closed audit errors; protect log paths and aliases from direct tools.
10
+ 增加私有权限、链接拒绝、协作写锁、落盘同步、日志限制及失败拒绝请求,保护日志路径和别名。
11
+ - Add source and packed-distribution regression coverage for audit writing and command UI.
12
+ 增加源码及实际发布包的日志写入和命令界面回归验证。
13
+
14
+ ## 0.2.1 — Compiled distribution / 编译产物发布
15
+
16
+ - Publish compiled, bundled and minified ESM in `dist/`, including both workers; exclude source and source maps from npm packages.
17
+ 发布 `dist/` 中编译合并压缩后的 ESM 及两个 worker,npm 包不再包含源码或 source map。
18
+ - Build automatically before tests and packing; validate the actual tarball through Pi's loader and worker execution.
19
+ 测试及打包前自动构建,使用 Pi 加载器和 worker 执行验证真实压缩包。
20
+ - Separate README into full English and Simplified Chinese sections with top language links.
21
+ README 改为完整英文在前、简体中文在后,顶部提供语言跳转链接。
22
+
3
23
  ## 0.2.0 — Machine configuration and memory cache / 机器配置与内存缓存
4
24
 
5
25
  - Derive deterministic filenames from OS machine identifiers using a product-specific hash; never fall back to random IDs.
@@ -13,7 +33,6 @@
13
33
  - Add migration, failure-injection, concurrency and cache regression tests, plus bilingual migration documentation.
14
34
  增加迁移、故障注入、并发及缓存回归测试和双语迁移文档。
15
35
 
16
-
17
36
  - Add tag-triggered GitHub Actions npm OIDC publishing, scoped environment permissions and manual dry-run validation.
18
37
  增加标签触发的 GitHub Actions npm OIDC 发布、限定环境权限及手动试运行验证。
19
38
  - 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
8
 
9
- ## Installation / 安装
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.**
10
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.
11
+ ## Installation
12
12
 
13
- 要求 Node.js 22+ 和 `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**,验证基线为 0.85.1。不支持旧 `@mariozechner/*` 版本。
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.
14
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.3.0
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,65 @@ 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
- - `/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 完整重载,不再只是查询状态;不支持通过命令参数添加秘密。
63
+ - `/protecter` opens the local audit viewer; `/protecter logs 50` shows up to 50 recent records. `/protecter status` reports memory counts without reading configuration. `/protecter reload` invokes Pi's full reload after idle. Do not enter secrets as command arguments.
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
+ ## Private audit log
67
+
68
+ Successful request-body redactions append to `protecter.<machineHash>.jsonl` beside the configuration. Each JSON line includes `time` (`yyyy-mm-dd HH:mm:ss`, local time), `timezoneOffset` (minutes east of UTC), `provider`, `original`, `replacement`, `requestId` and `version`. Provider is Pi's selected provider ID, not an inferred upstream gateway vendor; unavailable metadata is recorded as `unknown`.
69
+
70
+ One record is written per distinct original/token pair per successful scan; repeated occurrences in that request are deduplicated. Replacing the same original on a later request creates a new record, even when its token is reused. Existing tokens and requests without matches add no records. These are **local redaction events, not proof of network delivery**: cancellation, another hook or a provider failure can occur afterward.
71
+
72
+ `/protecter` or `/protecter logs [1-100]` reads the most recent records (20 by default). Local TUI only: choose a time/provider entry, then confirm before revealing plaintext. The editor is a preview and discards edits. Controls are escaped; long previews are truncated. RPC/print modes do not display secrets, and the command never calls `sendMessage` or writes session entries.
73
+
74
+ **Logs contain real secrets and reversible mappings.** Unix permissions are 0600; symlinks, hard links and other-user files are rejected. Tool guards cover current/old hash log names and aliases but are not a sandbox. Logs are not encrypted, automatically rotated, migrated with configuration or used to rebuild mappings. Stop all Pi processes before locally archiving/deleting logs or inspecting a stale `.jsonl.lock`. Reading is bounded to the last 1 MiB, display to 100 records and 20,000 characters per preview. A log is capped at 32 MiB and a request batch at 8 MiB; full, unwritable, partial-tail or locked logs block matched requests rather than silently losing audits. Failed scans add no records; a hard crash can leave a partial batch that requires local repair.
75
+
76
+ ## How it works
88
77
 
89
78
  ```text
90
79
  Input / history / tools / system prompt / tool definitions
91
- 输入/历史/工具结果/系统提示/工具定义
92
80
  ↓
93
- before_provider_request: final JSON text scan / 最终 JSON 文本扫描
81
+ before_provider_request: final JSON text scan
94
82
  ↓
95
- __PIP_<192-bit random hex>__ → LLM gateway / LLM 网关
83
+ __PIP_<192-bit random hex>__ → LLM gateway
96
84
  ↓
97
- Local response and tool-argument restoration / 本地回复和工具参数还原
85
+ Local response and tool-argument restoration
98
86
  ```
99
87
 
100
88
  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)` 生成占位符。映射**仅存于内存**,不写入配置、会话自定义条目、日志或模型提示;本地用户仍可看到原文。
89
+ 2. Generate tokens with `crypto.randomBytes(24)`. Runtime mappings stay in memory and are not restored across restarts. **Since 0.3.0, successful original/token pairs are also persisted in the private audit log.** They are never injected into configuration, session custom entries or model prompts.
104
90
  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
91
  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
92
  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
93
 
111
- ### Configuration access guard / 配置访问防护
94
+ ### Configuration access guard
112
95
 
113
96
  **Outgoing redaction is the main defense; direct-access blocking is supplementary.** Shell is not disabled wholesale.
114
97
 
115
- **以出站脱敏为主,直接访问拦截为辅。** 不全面禁用 shell。
116
-
117
98
  - 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
99
  - Resolve `@`, `~`, relative paths and `file://`; check symlink/inode aliases. Block `grep/find/ls` over ancestor directories containing the configuration.
120
- 解析上述路径形式,检查符号链接和 inode 别名;阻止这些搜索工具扫描包含配置的祖先目录。
121
100
  - Attempt whole-text masking if the complete original configuration accidentally appears in a request.
122
- 完整原始配置意外出现在请求文本时,尝试整体替换。
123
101
  - **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
102
 
126
- ## Limitations — read first / 当前限制——请先阅读
103
+ ## Limitations — read first
127
104
 
128
105
  - 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
106
  - `/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
107
  - 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
108
  - 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
109
  - Reused tokens reveal equality relationships; context may imply identity. This is not formal anonymization.
137
- 复用占位符会暴露值相等的关系,上下文也可能揭示身份,这不是形式化匿名化。
138
110
  - 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
111
  - **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
112
  - Local sessions, terminals, exports and files written by tools may contain plaintext. They are not encrypted or cleaned by this project.
143
- 本地会话、终端、导出和工具写出的文件可能包含明文,本项目不加密或清理它们。
144
113
 
145
- See [SECURITY.md](SECURITY.md) for the threat model. / 详细威胁模型见该安全文档。
114
+ See [SECURITY.md](SECURITY.md) for the threat model.
146
115
 
147
- ## Development and verification / 开发与验证
116
+ ## Development and verification
148
117
 
149
118
  ```bash
150
119
  npm ci
@@ -153,8 +122,132 @@ npm run check
153
122
 
154
123
  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
124
 
156
- 测试仅使用临时目录和虚构敏感值,不读取真实配置、不使用真实密钥或访问 LLM 网关,覆盖脱敏还原、正则超时、权限、路径防护和真实 Pi 加载器/执行器集成。所有 Markdown、注释和新提交信息必须使用英文+简体中文。
125
+ `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
126
 
158
127
  API references: [Pi Extensions](https://pi.dev/docs/latest/extensions), local 0.85.1 documentation and runner implementation. Licensed under MIT.
159
128
 
160
- 接口依据为 Pi 官方扩展文档、本机 0.85.1 文档及执行器实现;采用 MIT 许可证。
129
+ ---
130
+
131
+ ## 简体中文
132
+
133
+ **个人敏感信息保护插件。**
134
+
135
+ 这是一个 Pi 扩展:在本地审查 LLM 出站请求体,用随机占位符替换配置中的敏感值,并在本地还原模型返回的占位符。适用于 URL、电话号码、身份证/ID、姓名、凭证和密码。**这是基于规则的意外泄漏防护,不是沙箱、自动 PII 检测器或加密代理。**
136
+
137
+ ## 安装
138
+
139
+ 要求 Node.js 22+ 和 `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**,验证基线为 0.85.1。不支持旧 `@mariozechner/*` 版本。
140
+
141
+ ```bash
142
+ # 安装固定 npm 版本。
143
+ pi install npm:pi-information-protecter@0.3.0
144
+
145
+ # 或在本仓库构建后本地安装,不复制仓库。
146
+ npm ci
147
+ npm run build
148
+ pi install "$PWD"
149
+
150
+ # 单次加载。
151
+ pi -e ./src/index.ts
152
+ ```
153
+
154
+ 已运行的 Pi 请执行 `/reload`。从 `0.2.1` 起,npm 发布 `dist/` 中编译压缩后的 ESM,而非 TypeScript 源码。必须同时保留 `index.mjs`、`scan-worker.mjs` 和 `validate-worker.mjs` 三个文件。`0.2.0` 采用旧源码布局。
155
+
156
+ 版本变更见 [CHANGELOG.md](CHANGELOG.md)。贡献与发布流程见仓库内的 `CONTRIBUTING.md` 和 `docs/RELEASING.md`。
157
+
158
+ 维护者通过推送匹配版本的 `v*` 标签发布新版本。GitHub Actions 验证后使用 npm OIDC 可信发布,不需要长期令牌;手动试运行不会发布。
159
+
160
+ 初始化使用 `~/.pi/agent/protecter.<machineHash>.json`,遵循 `PI_CODING_AGENT_DIR`。哈希由产品前缀与规范化系统标识计算 SHA-256,取前 32 位十六进制;macOS 使用 `IOPlatformUUID`、Linux 使用 `/etc/machine-id`、Windows 使用 `MachineGuid`。标识缺失或无效时拒绝初始化,不随机回退。哈希是稳定标识而非秘密,不加载项目级配置。
161
+
162
+ 启动和重载时,识别到的旧配置(包括原固定文件名)会先验证,再合并去重到当前目标;目标持久化写入并验证后才删除未变化的旧文件。无效来源会中止迁移而不删除原文件。Unix 权限为 `0600`,拒绝不安全链接、他人所有文件和超限文件;Windows 需自行设置 ACL。详见[迁移文档](docs/MIGRATION.md)。
163
+
164
+ 验证后的配置按扩展实例缓存。出站扫描及状态命令不重新读取或检查配置文件;加载后修改、损坏或删除文件不改变当前快照。修改在重载后生效;`/protecter reload` 会调用 Pi 重载,会话切换产生的新实例也初始化一次。若全部配置已删除,下次初始化会创建空配置并警告,旧内存不跨实例销毁保留。
165
+
166
+ ## 配置
167
+
168
+ **请用本地编辑器修改配置,不要让模型填写真实密码,也不要把真实配置提交到 Git。** 初始规则数组为空,不具备个人信息检测能力,界面会给出警告。插件不会自动识别所有 SPI。
169
+
170
+ ```json
171
+ {
172
+ "version": 1,
173
+ "sensitiveWords": [
174
+ "张三",
175
+ { "type": "literal", "value": "your-private-password" },
176
+ { "type": "regex", "pattern": "https?://[^\\s\\\"<>]+", "flags": "i" },
177
+ { "type": "regex", "pattern": "(?<!\\d)1[3-9]\\d{9}(?!\\d)" }
178
+ ]
179
+ }
180
+ ```
181
+
182
+ 更多示例见 [`protecter.example.json`](protecter.example.json),不会自动启用。号码规则不验证真实性或校验位,姓名和密码建议使用精确值。
183
+
184
+ - **字面规则:**字符串和 `literal` 按大小写敏感方式匹配全部出现位置,不作正则解释。
185
+ - **正则:**JavaScript 正则替换整个匹配,可用后行断言仅匹配凭证值。支持 `g i m s u`,自动补 `g`,不支持 `/pattern/flags` 简写。
186
+ - JSON 中的反斜杠写作 `\\`。不允许匹配空字符串;运行时零宽匹配会拒绝请求。
187
+ - 合并重叠匹配,避免暴露长敏感值的后缀;同一实例内相同原文复用占位符。
188
+ - 已识别原文在当前实例内持续受到精确匹配保护,即使正则上下文消失或规则被删除;退出或重载清除记录。
189
+ - `/protecter` 打开本地审计查看器,`/protecter logs 50` 查看最近最多 50 条;`/protecter status` 只报告内存数量,不读取配置。`/protecter reload` 等待空闲后完整重载。不要在命令参数中填写秘密。
190
+ - 限制为 1000 条规则、每个词或模式 8192 字符、扫描 2 秒、请求 JSON 8 MiB;超限拒绝,不降级发送明文。
191
+
192
+ ## 私有审计日志
193
+
194
+ 成功脱敏的请求会追加记录到配置旁的 `protecter.<machineHash>.jsonl`。每行包含 `time`(本地时间 `yyyy-mm-dd HH:mm:ss`)、`timezoneOffset`(相对 UTC 向东的分钟数)、`provider`、`original`、`replacement`、`requestId` 和 `version`。供应商为 Pi 当前选择的 provider ID,不猜测网关背后的上游厂商;无法获取时记录 `unknown`。
195
+
196
+ 每次成功扫描,对不同的原文与占位符组合分别记录一行,同一请求内重复出现会去重。后续请求再次替换同一原文时仍新增记录,即使复用占位符。已有占位符和无命中请求不新增记录。日志表示**本地脱敏事件,而非网络已成功发送**,之后仍可能取消、被其他钩子修改或发生提供商错误。
197
+
198
+ `/protecter` 或 `/protecter logs [1-100]` 查看最近记录,默认 20 条。仅在本地 TUI 可用:先选择时间及供应商条目,确认后才显示明文。编辑器只作预览,修改不保存;控制字符转义,过长预览截断。RPC、打印模式不展示秘密,命令不调用消息发送接口,也不写入会话条目。
199
+
200
+ **日志包含真实秘密及可逆映射。** Unix 权限为 0600,拒绝符号链接、硬链接及他人文件。工具防护覆盖当前和旧机器哈希日志及别名,但不是沙箱。日志不加密、不自动轮转、不随配置迁移,也不用于恢复映射。请停止全部 Pi 进程后再本地归档、删除日志或检查残留 `.jsonl.lock`。读取限最后 1 MiB、最多 100 条,每条预览最多 20,000 字符;单日志上限 32 MiB、请求批次 8 MiB。日志满、不可写、尾部残缺或锁占用时,命中请求会被阻止,不静默漏记。扫描失败不新增记录,硬崩溃可能遗留部分批次,需本地修复。
201
+
202
+ ## 工作方式
203
+
204
+ ```text
205
+ 输入/历史/工具结果/系统提示/工具定义
206
+ ↓
207
+ before_provider_request:最终 JSON 文本扫描
208
+ ↓
209
+ __PIP_<192-bit 随机十六进制>__ → LLM 网关
210
+ ↓
211
+ 本地回复和工具参数还原
212
+ ```
213
+
214
+ 1. 扫描 JSON 字符串、键和数字,包括解码后的 JSON 字符串工具参数;不修改本地用户输入和历史原文。
215
+ 2. 使用 `crypto.randomBytes(24)` 生成占位符。运行时映射保存在内存,不跨重启恢复。**从 0.3.0 起,成功替换的原文和占位符也会持久化到私有审计日志。** 不注入配置、会话自定义条目或模型提示。
216
+ 3. 还原最终 assistant 文本、思考内容和工具参数。TUI 在完整占位符到达后还原,片段可能短暂显示;RPC/JSON 增量仍脱敏,直到最终消息还原。被改写或截断的占位符无法还原。
217
+ 4. 工具执行前还原参数,让合法本地操作使用原值;后续请求再次脱敏。**凭证还原不等于工具授权或防外传。**
218
+ 5. 正则扫描运行于可终止 worker。失败时返回 `{}` 并请求取消,不依赖被 Pi 吞掉的钩子异常;仍可能产生空请求或提供商校验错误,但处理器不返回原始请求体。
219
+
220
+ ### 配置访问防护
221
+
222
+ **以出站脱敏为主,直接访问拦截为辅。** 不全面禁用 shell。
223
+
224
+ - 文件工具检查实际路径参数而非正文,允许文档提及配置名称。当前配置目录内的旧名称、哈希名称及迁移文件受保护;shell 命令字段保留尽力文件名检查,因此 shell 提及名称仍可能误拦截。
225
+ - 解析 `@`、`~`、相对路径和 `file://`,检查符号链接和 inode 别名;阻止 `grep/find/ls` 扫描包含配置的祖先目录。
226
+ - 完整原始配置意外出现在请求文本时,尝试整体替换。
227
+ - **仅为尽力防护:**动态 shell、编码、拆分输出、目录内链接、任意第三方工具和网络发送可绕过检查,无法保证恶意模型永远无法取得配置。
228
+
229
+ ## 当前限制——请先阅读
230
+
231
+ - 仅保护命中规则的文本,不覆盖改写、拆字和任意编码。常见多模态附件因无法可靠审查而拒绝整个请求,不提供 OCR。
232
+ - 暂时取消压缩和带摘要的树导航,等待单独验证生命周期及还原;不带摘要的导航可用,长会话请使用 `/new`。
233
+ - 退出、重载和切换会话会丢弃映射。最终已还原消息仍可读,但崩溃残留、旧摘要和原始增量无法跨重启恢复占位符。
234
+ - 过宽规则可能改写协议字段、模型名、工具结构和 ID,导致请求失败;命中数字变为占位符字符串,应优先使用精确规则。
235
+ - 复用占位符会暴露值相等的关系,上下文也可能揭示身份,这不是形式化匿名化。
236
+ - 钩子不覆盖认证头、API key、网关地址、遥测、分享、扩展独立网络调用和工具联网;网关仍收到其认证凭证。
237
+ - **在其他请求改写扩展之后加载,并只使用可信扩展。** 后续处理器可重新加入原文;扩展有本机权限,插件加载失败时 Pi 可能继续运行,请先检查状态再输入秘密。
238
+ - 本地会话、终端、导出和工具写出的文件可能包含明文,本项目不加密或清理它们。
239
+
240
+ 详细威胁模型见 [SECURITY.md](SECURITY.md)。
241
+
242
+ ## 开发与验证
243
+
244
+ ```bash
245
+ npm ci
246
+ npm run check
247
+ ```
248
+
249
+ 测试仅使用临时目录和虚构敏感值,不读取真实配置、不使用真实密钥或访问 LLM 网关,覆盖脱敏还原、正则超时、权限、路径防护和真实 Pi 加载器/执行器集成。所有 Markdown、注释和新提交信息必须使用英文+简体中文。
250
+
251
+ `npm run build` 使用 esbuild 合并本地模块、移除类型并压缩产物,Node 内置模块和 Pi peer 依赖保持外部引用。不发布源码或 source map,源码仍可在 GitHub 查阅。压缩是体积优化,不是加密或安全隔离。`npm pack` 通过 `prepack` 自动构建,`npm publish` 先执行检查。测试还会解压实际 npm 压缩包,用 Pi 加载器和两个 worker 验证产物。详见仓库内的 `docs/BUILDING.md`。
252
+
253
+ 接口依据为 [Pi 官方扩展文档](https://pi.dev/docs/latest/extensions)、本机 0.85.1 文档及执行器实现;采用 MIT 许可证。
package/SECURITY.md CHANGED
@@ -19,8 +19,12 @@ Prevent configured personal information from accidentally entering Pi LLM reques
19
19
 
20
20
  ## Implementation / 实现策略
21
21
 
22
- - Scan outgoing payload text; use random 192-bit placeholders and memory-only reverse mappings.
23
- 扫描出站请求体文本,使用随机 192-bit 占位符及仅内存反向映射。
22
+ - Scan outgoing payload text with random 192-bit placeholders. Runtime mappings stay in memory, but version 0.3.0 adds plaintext original/token audit pairs in private JSONL. Audit files are sensitive credential stores, not encrypted archives; never upload them. Logs persist after exit and are not loaded to restore tokens.
23
+ 出站文本使用随机占位符,运行映射保存在内存,但 0.3.0 新增私有 JSONL 原文与占位符审计。日志属于敏感凭证存储,不是加密归档,禁止上传;退出后仍保留,不用于恢复占位符。
24
+ - Audit writes use 0600 files, safe-open checks, a cooperative lock and fsync before returning a redacted payload. Logging errors fail closed. Success means local replacement, not provider delivery. File size and preview limits bound resource usage; users manage retention locally. Hard crashes may leave stale locks or partial records. Same-UID writers and terminal capture remain outside the threat model.
25
+ 日志使用 0600、安全打开检查、协作锁,并在返回脱敏请求前同步落盘;日志失败则拒绝请求。成功表示本地替换而非网关已接收。文件及预览大小受限,用户本地管理保留策略;硬崩溃可能残留锁或不完整记录,同用户权限攻击和终端录屏仍不在防护范围。
26
+ - `/protecter` previews are TUI-only and require confirmation before showing original values; nothing is injected into model context or session entries. Trusted extensions may still observe UI hooks. Configuration caching remains memory-only during requests, but matched requests now perform audit-file writes.
27
+ 查看器仅限 TUI,展示原文前需确认,不注入模型上下文或会话条目;可信扩展仍可能观察 UI 钩子。出站规则依然使用内存缓存,但命中请求现在会写审计文件。
24
28
  - Limit worker regex scans to 2 seconds, with resource and input limits. Errors exclude original configuration, rules and request text.
25
29
  worker 正则扫描限制为 2 秒,并限制资源和输入;错误不包含原始配置、规则或请求文本。
26
30
  - 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.
package/dist/index.mjs ADDED
@@ -0,0 +1,9 @@
1
+ /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
2
+ import{getAgentDir as Ce,VERSION as ee}from"@earendil-works/pi-coding-agent";import{Worker as Ee}from"node:worker_threads";import{fileURLToPath as Oe}from"node:url";import*as l from"node:fs";import{join as v,resolve as ae}from"node:path";import{createHash as ce,randomUUID as fe}from"node:crypto";import{Worker as ue}from"node:worker_threads";import{fileURLToPath as le}from"node:url";import{constants as O,closeSync as F,fchmodSync as re,fstatSync as te,mkdirSync as ne,openSync as W,readFileSync as oe,writeFileSync as ie}from"node:fs";import{dirname as se}from"node:path";var _={version:1,sensitiveWords:[]},w="SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";function T(o){try{let e=JSON.parse(o);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(i=>!["type","pattern","flags"].includes(i))||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(w)}}function J(o,e=!1){let r;try{if(e){ne(se(o),{recursive:!0,mode:448});try{let n=W(o,O.O_WRONLY|O.O_CREAT|O.O_EXCL,384);try{ie(n,JSON.stringify(_,null,2)+`
3
+ `)}finally{F(n)}}catch(n){if(n.code!=="EEXIST")throw n}}r=W(o,O.O_RDONLY|O.O_NOFOLLOW|O.O_NONBLOCK);let t=te(r);if(!t.isFile()||t.nlink!==1||t.size>1024*1024||process.getuid&&t.uid!==process.getuid())throw new Error;process.platform!=="win32"&&re(r,384);let i=oe(r,"utf8");if(Buffer.byteLength(i)>1024*1024)throw new Error;return{config:T(i),raw:i}}catch{throw new Error(w)}finally{r!==void 0&&F(r)}}var A=/^protecter(?:\.[a-f0-9]{32})?\.json$/;function de(o){return new Promise((e,r)=>{let t=new ue(le(new URL("./validate-worker.mjs",import.meta.url)),{workerData:o,execArgv:[],resourceLimits:{maxOldGenerationSizeMb:64}}),i=!1,n=u=>{i||(i=!0,clearTimeout(s),t.terminate(),u?e():r(new Error(w)))},s=setTimeout(()=>n(!1),2e3);t.once("message",u=>n(u===!0)),t.once("error",()=>n(!1)),t.once("exit",()=>n(!1))})}function ge(o){let e=new Set,r=[];for(let i of o)for(let n of i.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(w);return T(t)}function j(o){let e=l.lstatSync(o,{bigint:!0});if(!e.isFile()||e.nlink!==1n||process.getuid&&e.uid!==BigInt(process.getuid()))throw new Error(w);return`${e.dev}:${e.ino}:${e.size}:${e.mtimeNs}`}function I(o,e){let r=v(o,e),t=j(r),i=J(r);if(j(r)!==t)throw new Error(w);return{name:e,...i,identity:t,digest:ce("sha256").update(i.raw).digest("hex")}}function b(o,e){let r=I(o,e.name);if(r.identity!==e.identity||r.digest!==e.digest)throw new Error(w)}function N(o){return l.readdirSync(o).filter(e=>A.test(e)).sort()}function U(o){if(process.platform==="win32")return;let e=l.openSync(o,l.constants.O_RDONLY);try{l.fsyncSync(e)}finally{l.closeSync(e)}}async function z(o,e,r={}){if(!/^[a-f0-9]{32}$/.test(e))throw new Error(w);let t=ae(o),i=`protecter.${e}.json`,n=v(t,i);l.mkdirSync(t,{recursive:!0,mode:448});let s=v(t,".protecter-migration.lock"),u=Date.now()+(r.lockTimeoutMs??5e3);for(;;)try{l.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=u)throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");await new Promise(g=>setTimeout(g,50))}let a;try{let d=N(t);if(d.length>64)throw new Error(w);let c=[...d].sort((h,D)=>h===i?-1:D===i?1:h.localeCompare(D)).map(h=>I(t,h)),m=ge(c.length?c.map(h=>h.config):[_]);if(await de(c.length?c.map(h=>h.config):[m]),c.length===1&&c[0].name===i){if(b(t,c[0]),JSON.stringify(N(t))!==JSON.stringify(d))throw new Error(w);return{path:n,config:c[0].config,raw:c[0].raw,sourceRaws:[c[0].raw],migrated:0}}let p=JSON.stringify(m,null,2)+`
4
+ `;if(Buffer.byteLength(p)>1024*1024)throw new Error(w);a=v(t,`.protecter-migration.${fe()}.tmp`);let y=l.openSync(a,l.constants.O_WRONLY|l.constants.O_CREAT|l.constants.O_EXCL,384);try{l.writeFileSync(y,p),l.fsyncSync(y)}finally{l.closeSync(y)}if(r.checkpoint?.("beforeCommit",n),JSON.stringify(N(t))!==JSON.stringify(d))throw new Error(w);for(let h of c)b(t,h);l.renameSync(a,n),a=void 0,U(t);let E=I(t,i);if(E.raw!==p)throw new Error(w);r.checkpoint?.("afterCommit",n);for(let h of c)h.name!==i&&(r.checkpoint?.("beforeDelete",v(t,h.name)),b(t,E),b(t,h),l.unlinkSync(v(t,h.name)));U(t);let R=[i];if(JSON.stringify(N(t))!==JSON.stringify(R))throw new Error(w);return b(t,E),{path:n,config:m,raw:p,sourceRaws:[...new Set([...c.map(h=>h.raw),p])],migrated:c.filter(h=>h.name!==i).length}}catch{throw new Error(w)}finally{if(a)try{l.unlinkSync(a)}catch{}l.rmdirSync(s)}}import{createHash as me}from"node:crypto";import{readFileSync as pe}from"node:fs";import{execFileSync as B}from"node:child_process";var G="SPI Protecter: machine ID unavailable / 无法获取机器标识。";function L(o){let e=o.trim().toLowerCase().replace(/-/g,"");if(!/^[a-f0-9]{32}$/.test(e)||/^0+$/.test(e))throw new Error(G);return me("sha256").update(`pi-information-protecter:v1:${e}`).digest("hex").slice(0,32)}function q(){try{if(process.platform==="linux")return L(pe("/etc/machine-id","utf8"));if(process.platform==="darwin"){let o=B("/usr/sbin/ioreg",["-rd1","-c","IOPlatformExpertDevice"],{encoding:"utf8",timeout:5e3,maxBuffer:1048576,stdio:["ignore","pipe","pipe"]});return L(o.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1]??"")}if(process.platform==="win32"){let o=B("reg.exe",["query","HKLM\\SOFTWARE\\Microsoft\\Cryptography","/v","MachineGuid","/reg:64"],{encoding:"utf8",timeout:5e3,maxBuffer:65536,stdio:["ignore","pipe","pipe"]});return L(o.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1]??"")}}catch{}throw new Error(G)}import*as f from"node:fs";import{randomUUID as he}from"node:crypto";var k="SPI Protecter: audit unavailable; request blocked / 审计日志不可用,请求已阻止。",we=32*1024*1024,ye=8*1024*1024;function Se(o){let e=r=>String(r).padStart(2,"0");return`${o.getFullYear()}-${e(o.getMonth()+1)}-${e(o.getDate())} ${e(o.getHours())}:${e(o.getMinutes())}:${e(o.getSeconds())}`}function H(o,e){let r=f.openSync(o,(e?f.constants.O_RDWR|f.constants.O_CREAT:f.constants.O_RDONLY)|f.constants.O_NOFOLLOW|f.constants.O_NONBLOCK,384);try{let t=f.fstatSync(r);if(!t.isFile()||t.nlink!==1||process.getuid&&t.uid!==process.getuid())throw new Error;return process.platform!=="win32"&&f.fchmodSync(r,384),r}catch{throw f.closeSync(r),new Error(k)}}async function X(o,e,r){if(!r.length)return;let t=new Date,i=he(),n=Buffer.from(r.map(([d,g])=>JSON.stringify({version:1,time:Se(t),timezoneOffset:-t.getTimezoneOffset(),requestId:i,provider:e||"unknown",original:g,replacement:d})).join(`
5
+ `)+`
6
+ `);if(n.length>ye)throw new Error(k);let s=`${o}.lock`,u=Date.now()+2e3;for(;;)try{f.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=u)throw new Error(k);await new Promise(g=>setTimeout(g,25))}let a;try{a=H(o,!0);let d=f.fstatSync(a).size;if(d+n.length>we)throw new Error;if(d){let g=Buffer.alloc(1);if(f.readSync(a,g,0,1,d-1),g[0]!==10)throw new Error}try{let g=0;for(;g<n.length;){let c=f.writeSync(a,n,g,n.length-g,d+g);if(!c)throw new Error;g+=c}f.fsyncSync(a)}catch{throw f.ftruncateSync(a,d),f.fsyncSync(a),new Error}}catch{throw new Error(k)}finally{a!==void 0&&f.closeSync(a),f.rmdirSync(s)}}function Y(o,e=20){if(!Number.isInteger(e)||e<1||e>100)throw new Error(k);let r;try{try{r=H(o,!1)}catch(m){if(m.code==="ENOENT")return{records:[],truncated:!1};throw m}let t=f.fstatSync(r).size,i=Math.max(0,t-1024*1024),n=Buffer.alloc(t-i),s=f.readSync(r,n,0,n.length,i),u=n.subarray(0,s).toString("utf8");if(i){let m=u.indexOf(`
7
+ `);u=m<0?"":u.slice(m+1)}let a=u.split(`
8
+ `),d=a.pop()!=="",g=[],c=!1;for(let m of a.slice(-e))try{let p=JSON.parse(m);if(p.version!==1||typeof p.time!="string"||typeof p.provider!="string"||typeof p.original!="string"||typeof p.replacement!="string"||typeof p.requestId!="string"||typeof p.timezoneOffset!="number")throw new Error;g.push(p)}catch{c=!0}return{records:g,truncated:i>0||a.length>e||d||c}}catch{throw new Error(k)}finally{r!==void 0&&f.closeSync(r)}}var S="SPI Protecter: request cleared; check config, audit log, scan limits or attachments / 请求已清空,请检查配置、审计日志、扫描限制或附件。",P=class{constructor(e,r=2e3,t=q){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(S);this.snapshot=void 0;let e=await z(this.configDir,this.machineId());if(this.closed)throw new Error(S);this.snapshot=e}get configPath(){return this.snapshot?.path??""}get auditPath(){return this.configPath?this.configPath.replace(/\.json$/,".jsonl"):""}auditRecords(e=20){if(!this.ready)throw new Error(S);return Y(this.auditPath,e)}get ruleCount(){return this.snapshot?.config.sensitiveWords.length??0}get ready(){return!!this.snapshot&&!this.closed}get mappingCount(){return this.tokens.size}redact(e,r="unknown"){let t=this.queue.then(()=>this.scan(e,r));return this.queue=t.catch(()=>{}),t}async scan(e,r){if(!this.ready)throw new Error(S);let{config:t,sourceRaws:i}=this.snapshot,n=JSON.stringify(e);if(!n||Buffer.byteLength(n)>8*1024*1024)throw new Error(S);let s;try{s=JSON.parse(n)}catch{throw new Error(S)}let u=await new Promise((a,d)=>{let g=!1,c=new Ee(Oe(new URL("./scan-worker.mjs",import.meta.url)),{workerData:{payload:s,rules:t.sensitiveWords,entries:[...this.tokens],configRaws:i},execArgv:[],resourceLimits:{maxOldGenerationSizeMb:128}});this.workers.add(c);let m=y=>{if(!g){if(g=!0,clearTimeout(p),this.workers.delete(c),c.terminate(),!y||this.closed){d(new Error(S));return}a(y)}},p=setTimeout(()=>m(),this.timeoutMs);c.once("message",y=>m(y.failed?void 0:y)),c.once("error",()=>m()),c.once("exit",()=>m())});if(this.closed)throw new Error(S);if(await X(this.auditPath,r,u.hits),this.closed)throw new Error(S);for(let[a,d]of u.additions)this.tokens.set(a,d);return u.payload}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[i,n]of Object.entries(e)){let s=this.restoreText(i);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 ve,statSync as K,readdirSync as ke}from"node:fs";import{homedir as Re}from"node:os";import{basename as $,dirname as x,isAbsolute as be,relative as V,resolve as C}from"node:path";import{fileURLToPath as xe}from"node:url";function Ne(o,e){let r=o.replace(/^@/,"");return r.startsWith("file://")&&(r=xe(r)),(r==="~"||r.startsWith("~/"))&&(r=Re()+r.slice(1)),C(e,r)}function M(o){try{return ve(o)}catch{let e=x(o);return e===o?o:C(M(e),V(e,o))}}function Pe(o,e){let r=V(o,e);return r===""||!r.startsWith("..")&&!be(r)}function Q(o,e){try{let r=K(o),t=K(e);return r.dev===t.dev&&r.ino===t.ino}catch{return!1}}function Z(o,e,r,t){if(!t)return!0;let i=M(C(t));if(["bash","powershell"].includes(o)){let u=typeof e.command=="string"?e.command:"";if(/protecter(?:\.[a-f0-9]{32})?\.jsonl?|\.protecter-migration/i.test(u)||u.includes(t))return!0}let n=e.path??e.file_path??e.filePath,s=["grep","find","ls"].includes(o);if(typeof n!="string"&&!s)return!1;try{let u=Ne(typeof n=="string"?n:".",r),a=M(u),d=/^protecter\.[a-f0-9]{32}\.jsonl(?:\.lock)?$/,g=ke(x(i)).filter(c=>d.test(c)).some(c=>Q(u,C(x(i),c)));return x(a)===x(i)&&(A.test($(a))||d.test($(a))||$(a).startsWith(".protecter-migration"))||a===i||Q(u,t)||g||s&&Pe(a,i)}catch{return!0}}function _e(o){let e=new P(Ce()),r=/^0\.85\./.test(ee)&&Number(ee.split(".")[2])>=1,t=!1;o.on("session_start",async(i,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(S,"error")}}),o.on("before_provider_request",async(i,n)=>{try{if(!r)throw new Error;let s=await e.redact(i.payload,n.model?.provider??"unknown");return t=!0,s}catch{t=!1;try{await n.abort()}catch{}try{n.ui.notify(S,"error")}catch{}return{}}}),o.on("message_end",i=>{if(i.message.role!=="assistant")return;let n=i.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)}:{}}}}),o.registerMarkdownTransformer((i,n)=>n.messageType==="user"?i:e.restoreText(i)),o.on("tool_call",(i,n)=>{if(!t)return{block:!0,reason:"SPI Protecter 尚未就绪。"};let s=e.restore(i.input);for(let u of Object.keys(i.input))delete i.input[u];if(Object.assign(i.input,s),Z(i.toolName,i.input,n.cwd,e.configPath))return{block:!0,reason:"SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。"}}),o.on("session_before_compact",(i,n)=>(n.ui.notify("SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。","warning"),{cancel:!0})),o.on("session_before_tree",i=>i.preparation.userWantsSummary?{cancel:!0}:void 0),o.registerCommand("protecter",{description:"View local protection records / 查看本地保护记录",handler:async(i,n)=>{let s=i.trim().split(/\s+/).filter(Boolean),u=s[0]??"logs";if(!["status","reload","logs"].includes(u)||u!=="logs"&&s.length>1||s.length>2||s[1]!==void 0&&!/^(?:[1-9]\d?|100)$/.test(s[1])){n.ui.notify("Usage / 用法: /protecter [logs [1-100]|status|reload]. No secrets in arguments / 不要在参数中输入秘密。","info");return}if(u==="logs"){if(n.mode!=="tui"){n.ui.notify("Local TUI required / 请在本地 TUI 查看记录。","warning");return}try{let{records:a,truncated:d}=e.auditRecords(Number(s[1]??20));if(!a.length){n.ui.notify("No readable records / 暂无可读记录。","info");return}let g=E=>JSON.stringify(E).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,R=>`\\u${R.charCodeAt(0).toString(16).padStart(4,"0")}`),c=a.map((E,R)=>`${R+1}. ${g(E.time)} | ${g(E.provider).slice(0,120)}`),m=await n.ui.select(`Protection records / 保护记录${d?" (limited tail / 有限尾部)":""}`,c),p=m===void 0?-1:c.indexOf(m);if(p<0||!await n.ui.confirm("Sensitive plaintext / 敏感明文","Reveal locally? Never share this view / 确认本地显示?请勿分享此界面。"))return;let y=g(a[p]);await n.ui.editor("Local preview; edits discarded / 本地预览,编辑不保存",y.length>2e4?y.slice(0,2e4)+`
9
+ [Preview truncated / 预览截断]`:y)}catch{n.ui.notify("Cannot read audit records / 无法读取审计记录。","error")}return}if(u==="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")}}),o.on("session_shutdown",()=>e.close())}export{_e as default};
@@ -0,0 +1,2 @@
1
+ /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
2
+ import{parentPort as x,workerData as k}from"node:worker_threads";import{randomBytes as M}from"node:crypto";try{let _=function(t){let e=l.get(t);if(e)return e;if(c.size>=5e4)throw new Error;do e=`__PIP_${M(24).toString("hex")}__`;while(c.has(e)||j.includes(e));return l.set(t,e),c.set(e,t),w.push([e,t]),e},h=function(t){let e=[];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(e.push([n.index,n.index+n[0].length]),++u>1e5)throw new Error}}else{if(!o.literal)continue;let n=0;for(;(n=t.indexOf(o.literal,n))!==-1;){if(e.push([n,n+o.literal.length]),++u>1e5)throw new Error;n++}}e.sort((o,n)=>o[0]-n[0]||n[1]-o[1]);let r=[];for(let o of e){let n=r.at(-1);n&&o[0]<n[1]?n[1]=Math.max(n[1],o[1]):r.push([...o])}let s="",i=0;for(let[o,n]of r){let S=_(t.slice(o,n));m.add(S),s+=t.slice(i,o)+S,i=n}return s+t.slice(i)},f=function(t){let e="",r=0;for(let s of t.matchAll(/__PIP_[a-f0-9]{48}__/g))c.has(s[0])&&(e+=h(t.slice(r,s.index))+s[0],r=s.index+s[0].length);return e+h(t.slice(r))},a=function(t,e=0){if(++J>2e5||e>80)throw new Error;if(typeof t=="string"){if(y.some(r=>t.includes(r)))return f(t);if(/^\s*[[{]/.test(t)){let r;try{r=JSON.parse(t)}catch{}if(r&&typeof r=="object"){let s=JSON.stringify(a(r,e+1)),i=f(s);return i===s?s:(JSON.parse(i),i)}}return f(t)}if(typeof t=="number"){let r=f(String(t));return r===String(t)?t:r}if(Array.isArray(t))return t.map(r=>a(r,e+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 r=Object.create(null);for(let[s,i]of Object.entries(t)){let o=f(s);if(Object.hasOwn(r,o))throw new Error;r[o]=a(i,e+1)}return r}if(t===null||typeof t=="boolean")return t;throw new Error};A=_,P=h,D=f,I=a;let{payload:d,rules:E,entries:g,configRaws:b}=k,l=new Map(g.map(([t,e])=>[e,t])),c=new Map(g),w=[],m=new Set,p=E.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 y=[...new Set(b.flatMap(t=>[t,JSON.stringify(JSON.parse(t))]))];for(let t of y)p.push({literal:t});let j=JSON.stringify(d),u=0,J=0,O=a(d),N=JSON.stringify(O),T=[...m].filter(t=>N.includes(t)).map(t=>[t,c.get(t)]);x.postMessage({payload:O,additions:w,hits:T})}catch{x.postMessage({failed:!0})}var A,P,D,I;
@@ -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.3.0",
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
- }