pi-information-protecter 0.2.1 → 0.4.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,27 @@
1
1
  # Changelog / 更新日志
2
2
 
3
+ ## 0.4.0 — Fixed replacement aliases / 固定替换别名
4
+
5
+ - Add optional literal `replacement` strings to literal and regex rules; omitted targets retain stable per-instance random placeholders.
6
+ 字面与正则规则支持可选固定 `replacement` 字符串,省略时保持实例内稳定随机占位符。
7
+ - Restore fixed aliases locally in responses and tool arguments, and record the actual target in private audit logs, including JSON-escaped values.
8
+ 在本地响应和工具参数中还原固定别名,审计记录实际替换目标,支持 JSON 转义值。
9
+ - Reject invalid or sensitive targets, conflicting definitions, ambiguous inverse mappings and partial overlaps involving fixed rules; preserve targets during safe migration.
10
+ 拒绝非法或含敏感值的目标、冲突定义、歧义反向映射及固定规则部分重叠,安全迁移保留目标。
11
+ - Add fixed/random coexistence, multi-turn consistency and packed-distribution regressions; document reserved-alias and prompt-cache boundaries.
12
+ 增加固定与随机共存、多轮一致性和实际发布包回归,说明保留别名及提示词缓存边界。
13
+
14
+ ## 0.3.0 — Private redaction audit / 私有脱敏审计
15
+
16
+ - 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.
17
+ 按请求持久化唯一原文与占位符到私有 JSONL,含本地时间、时区偏移、供应商及请求 ID;明确新增磁盘敏感明文,记录表示本地脱敏而非网络已送达。
18
+ - `/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.
19
+ 命令提供有限本地查看及确认,保留状态和重载,不将审计注入模型或会话。
20
+ - 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.
21
+ 增加私有权限、链接拒绝、协作写锁、落盘同步、日志限制及失败拒绝请求,保护日志路径和别名。
22
+ - Add source and packed-distribution regression coverage for audit writing and command UI.
23
+ 增加源码及实际发布包的日志写入和命令界面回归验证。
24
+
3
25
  ## 0.2.1 — Compiled distribution / 编译产物发布
4
26
 
5
27
  - Publish compiled, bundled and minified ESM in `dist/`, including both workers; exclude source and source maps from npm packages.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Requires Node.js 22+ and `@earendil-works/pi-coding-agent` **0.85.1–0.85.x**,
14
14
 
15
15
  ```bash
16
16
  # Install the pinned npm release.
17
- pi install npm:pi-information-protecter@0.2.1
17
+ pi install npm:pi-information-protecter@0.4.0
18
18
 
19
19
  # Alternatively, build and install from this repository without copying it.
20
20
  npm ci
@@ -60,9 +60,42 @@ More examples are in [`protecter.example.json`](protecter.example.json); they ar
60
60
  - Escape backslashes as `\\` in JSON. Empty-string matches are invalid; runtime zero-width matches reject the request.
61
61
  - Overlapping matches are merged to avoid exposing a longer secret's suffix. Equal values reuse a token within the extension instance.
62
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.
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.
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.
64
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.
65
65
 
66
+ ### Fixed replacement values
67
+
68
+ Object rules accept an optional `replacement`. Omit it for the existing random behavior; strings still always use random placeholders. Empty, whitespace-only, non-string or over-8192-character targets are invalid (not a random fallback).
69
+
70
+ ```json
71
+ {
72
+ "version": 1,
73
+ "sensitiveWords": [
74
+ { "type": "literal", "value": "Apple Inc.", "replacement": "Alphabet Inc." },
75
+ { "type": "literal", "value": "example-private-key-123", "replacement": "MyPrivateKey" },
76
+ { "type": "literal", "value": "randomly-protected-value" }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ Targets are literal text, not replacement templates: `$1` and `$&` are not expanded. Both literal and regex object rules support this field. Fixed aliases appear in audit records and are restored locally in replies and tool arguments after a successful scan. They are **reserved aliases**: naturally occurring identical response text will also be restored; choose distinctive targets and avoid ordinary words when that ambiguity matters. Fixed aliases expose more semantic information than random tokens.
82
+
83
+ For reversible protection, duplicate targets across different rules, substring-overlapping targets, targets containing configured sensitive values, reserved `__PIP_` names and conflicting definitions are rejected. A fixed regex can represent only one distinct matched original per instance; a second distinct original rejects the request. Partially overlapping matches involving fixed rules also reject rather than silently choose a rule or expose a suffix. Pure random-rule overlaps still merge. Migration preserves targets and refuses conflicting merges without deleting originals. Reload after editing; older plugin versions do not support this field.
84
+
85
+ ### Multi-turn consistency and prompt caching
86
+
87
+ Random placeholders are generated once per exact original and reused within the same extension instance, including after local response restoration. Starting another scan worker does not reset the mapping. This supports stable prompt prefixes but does not guarantee provider KV-cache hits; model, tools, message ordering, cache lifetime and routing also matter. `/reload`, process restart and session-instance replacement clear mappings. Different processes do not share mappings, and audit timestamps/request IDs are never included in the model payload.
88
+
89
+ ## Private audit log
90
+
91
+ 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`.
92
+
93
+ 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.
94
+
95
+ `/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.
96
+
97
+ **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.
98
+
66
99
  ## How it works
67
100
 
68
101
  ```text
@@ -76,7 +109,7 @@ Local response and tool-argument restoration
76
109
  ```
77
110
 
78
111
  1. Scan JSON strings, keys and numbers, including decoded JSON-string tool arguments. Local user input and original history are unchanged.
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.
112
+ 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.
80
113
  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.
81
114
  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.**
82
115
  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.
@@ -130,7 +163,7 @@ API references: [Pi Extensions](https://pi.dev/docs/latest/extensions), local 0.
130
163
 
131
164
  ```bash
132
165
  # 安装固定 npm 版本。
133
- pi install npm:pi-information-protecter@0.2.1
166
+ pi install npm:pi-information-protecter@0.4.0
134
167
 
135
168
  # 或在本仓库构建后本地安装,不复制仓库。
136
169
  npm ci
@@ -176,9 +209,42 @@ pi -e ./src/index.ts
176
209
  - JSON 中的反斜杠写作 `\\`。不允许匹配空字符串;运行时零宽匹配会拒绝请求。
177
210
  - 合并重叠匹配,避免暴露长敏感值的后缀;同一实例内相同原文复用占位符。
178
211
  - 已识别原文在当前实例内持续受到精确匹配保护,即使正则上下文消失或规则被删除;退出或重载清除记录。
179
- - 状态命令只报告缓存数量,不访问磁盘或显示敏感值。重载命令等待空闲并调用 Pi 完整重载,不再只是查询状态;不支持通过命令参数添加秘密。
212
+ - `/protecter` 打开本地审计查看器,`/protecter logs 50` 查看最近最多 50 条;`/protecter status` 只报告内存数量,不读取配置。`/protecter reload` 等待空闲后完整重载。不要在命令参数中填写秘密。
180
213
  - 限制为 1000 条规则、每个词或模式 8192 字符、扫描 2 秒、请求 JSON 8 MiB;超限拒绝,不降级发送明文。
181
214
 
215
+ ### 固定替换值
216
+
217
+ 对象规则支持可选 `replacement` 字段。省略时保持随机替换,纯字符串规则始终使用随机占位符。空字符串、全空白、非字符串或超过 8192 字符的目标属于无效配置,不回退为随机。
218
+
219
+ ```json
220
+ {
221
+ "version": 1,
222
+ "sensitiveWords": [
223
+ { "type": "literal", "value": "Apple Inc.", "replacement": "Alphabet Inc." },
224
+ { "type": "literal", "value": "example-private-key-123", "replacement": "MyPrivateKey" },
225
+ { "type": "literal", "value": "randomly-protected-value" }
226
+ ]
227
+ }
228
+ ```
229
+
230
+ 目标按字面文本处理,不是替换模板,`$1`、`$&` 不展开。字面和正则对象均支持该字段。成功扫描后,固定别名同样写入审计,并在本地回复和工具参数中还原。它属于**保留别名**:回复中自然出现的相同文本也会被还原;如果介意此歧义,请选择独特目标而非普通词。固定别名比随机占位符暴露更多语义信息。
231
+
232
+ 为保持可逆保护,不同规则复用相同目标、目标互为子串、目标包含配置敏感值、使用保留 `__PIP_` 名称或定义冲突时拒绝加载。固定正则在同一实例中只能表示一种不同的匹配原文,第二种原文会导致请求被拒绝。涉及固定规则的部分重叠匹配也拒绝,不静默选择规则或泄漏后缀;纯随机规则重叠仍合并。迁移保留固定目标,遇到冲突停止且不删除原文件。修改后请重载;旧插件版本不支持此字段。
233
+
234
+ ### 多轮一致性与提示词缓存
235
+
236
+ 随机占位符按精确原文首次生成,在同一扩展实例内持续复用,包括本地回复还原后的再次脱敏;新建扫描 worker 不会重置映射。这有助于前缀稳定,但不保证提供商 KV Cache 命中,模型、工具、消息顺序、缓存有效期和路由也有影响。重载、进程重启和会话实例替换会清除映射;不同进程不共享映射,审计时间和请求 ID 不进入模型请求体。
237
+
238
+ ## 私有审计日志
239
+
240
+ 成功脱敏的请求会追加记录到配置旁的 `protecter.<machineHash>.jsonl`。每行包含 `time`(本地时间 `yyyy-mm-dd HH:mm:ss`)、`timezoneOffset`(相对 UTC 向东的分钟数)、`provider`、`original`、`replacement`、`requestId` 和 `version`。供应商为 Pi 当前选择的 provider ID,不猜测网关背后的上游厂商;无法获取时记录 `unknown`。
241
+
242
+ 每次成功扫描,对不同的原文与占位符组合分别记录一行,同一请求内重复出现会去重。后续请求再次替换同一原文时仍新增记录,即使复用占位符。已有占位符和无命中请求不新增记录。日志表示**本地脱敏事件,而非网络已成功发送**,之后仍可能取消、被其他钩子修改或发生提供商错误。
243
+
244
+ `/protecter` 或 `/protecter logs [1-100]` 查看最近记录,默认 20 条。仅在本地 TUI 可用:先选择时间及供应商条目,确认后才显示明文。编辑器只作预览,修改不保存;控制字符转义,过长预览截断。RPC、打印模式不展示秘密,命令不调用消息发送接口,也不写入会话条目。
245
+
246
+ **日志包含真实秘密及可逆映射。** Unix 权限为 0600,拒绝符号链接、硬链接及他人文件。工具防护覆盖当前和旧机器哈希日志及别名,但不是沙箱。日志不加密、不自动轮转、不随配置迁移,也不用于恢复映射。请停止全部 Pi 进程后再本地归档、删除日志或检查残留 `.jsonl.lock`。读取限最后 1 MiB、最多 100 条,每条预览最多 20,000 字符;单日志上限 32 MiB、请求批次 8 MiB。日志满、不可写、尾部残缺或锁占用时,命中请求会被阻止,不静默漏记。扫描失败不新增记录,硬崩溃可能遗留部分批次,需本地修复。
247
+
182
248
  ## 工作方式
183
249
 
184
250
  ```text
@@ -192,7 +258,7 @@ __PIP_<192-bit 随机十六进制>__ → LLM 网关
192
258
  ```
193
259
 
194
260
  1. 扫描 JSON 字符串、键和数字,包括解码后的 JSON 字符串工具参数;不修改本地用户输入和历史原文。
195
- 2. 使用 `crypto.randomBytes(24)` 生成占位符。映射**仅存于内存**,不写入配置、会话自定义条目、日志或模型提示;本地用户仍可看到原文。
261
+ 2. 使用 `crypto.randomBytes(24)` 生成占位符。运行时映射保存在内存,不跨重启恢复。**从 0.3.0 起,成功替换的原文和占位符也会持久化到私有审计日志。** 不注入配置、会话自定义条目或模型提示。
196
262
  3. 还原最终 assistant 文本、思考内容和工具参数。TUI 在完整占位符到达后还原,片段可能短暂显示;RPC/JSON 增量仍脱敏,直到最终消息还原。被改写或截断的占位符无法还原。
197
263
  4. 工具执行前还原参数,让合法本地操作使用原值;后续请求再次脱敏。**凭证还原不等于工具授权或防外传。**
198
264
  5. 正则扫描运行于可终止 worker。失败时返回 `{}` 并请求取消,不依赖被 Pi 吞掉的钩子异常;仍可能产生空请求或提供商校验错误,但处理器不返回原始请求体。
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.
@@ -36,6 +40,12 @@ Prevent configured personal information from accidentally entering Pi LLM reques
36
40
  - Compaction and tree summaries are conservatively disabled. Do not widen Pi compatibility without verification.
37
41
  保守禁用压缩和树摘要,未经验证不扩大 Pi 兼容范围。
38
42
 
43
+ ## Fixed aliases and caching / 固定别名与缓存
44
+
45
+ Fixed targets are reserved literal aliases, not anonymization: they reveal semantics and natural occurrences in model output are also restored after the mapping is learned. Choose distinctive values. Reusing one alias for distinct originals, sensitive targets, ambiguous substring aliases and conflicting matchers is rejected rather than guessing a reverse mapping. Runtime regex collisions and partial fixed-rule overlaps fail closed. Random placeholders remain stable within one extension instance; cache hits still depend on provider prefix, model and routing policies. Reload/restart discards mappings; audit records are not loaded to restore them.
46
+
47
+ 固定目标是保留字面别名,并非匿名化;它会暴露语义,映射学习后模型输出中自然出现的同名内容也会被还原,应选择独特值。不同原文复用别名、敏感目标、子串歧义和匹配器冲突会被拒绝,不猜测反向映射。运行时正则冲突及固定规则部分重叠会拒绝请求。随机占位符在同一实例内保持稳定,但缓存命中仍依赖提供商前缀、模型和路由策略。重载或重启丢弃映射,不从审计恢复。
48
+
39
49
  ## Deployment guidance / 安全部署建议
40
50
 
41
51
  1. Configure precise rules locally and check status/counts. Empty rules provide no SPI detection.
package/dist/index.mjs CHANGED
@@ -1,4 +1,9 @@
1
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};
2
+ import{getAgentDir as _e,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 u from"node:fs";import{join as k,resolve as ae}from"node:path";import{createHash as ce,randomUUID as fe}from"node:crypto";import{Worker as le}from"node:worker_threads";import{fileURLToPath as ue}from"node:url";import{constants as O,closeSync as J,fchmodSync as te,fstatSync as re,mkdirSync as ne,openSync as j,readFileSync as oe,writeFileSync as ie}from"node:fs";import{dirname as se}from"node:path";var C={version:1,sensitiveWords:[]},w="SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";function I(o){try{let e=JSON.parse(o);if(!e||e.version!==1||!Array.isArray(e.sensitiveWords)||e.sensitiveWords.length>1e3||Object.keys(e).some(t=>!["version","sensitiveWords"].includes(t)))throw new Error;let n=new Map;for(let t of e.sensitiveWords){if(typeof t=="string"){if(!t||t.length>8192)throw new Error;let r=JSON.stringify(["literal",t]);if(n.has(r)&&n.get(r)!==void 0)throw new Error;n.set(r,void 0);continue}if(!t||typeof t!="object")throw new Error;if(Object.hasOwn(t,"replacement")&&(typeof t.replacement!="string"||!t.replacement.trim()||t.replacement.length>8192||t.replacement.includes("__PIP_")))throw new Error;if(t.type==="literal"){if(Object.keys(t).some(r=>!["type","value","replacement"].includes(r))||typeof t.value!="string"||!t.value||t.value.length>8192)throw new Error}else if(t.type==="regex"){if(Object.keys(t).some(s=>!["type","pattern","flags","replacement"].includes(s))||typeof t.pattern!="string"||!t.pattern||t.pattern.length>8192||t.flags!==void 0&&(typeof t.flags!="string"||!/^[gimsu]*$/.test(t.flags)))throw new Error;let r=new RegExp(t.pattern,t.flags??"")}else throw new Error;let i=t.type==="literal"?JSON.stringify(["literal",t.value]):JSON.stringify(["regex",t.pattern,[...new Set((t.flags??"")+"g")].sort().join("")]);if(n.has(i)&&n.get(i)!==t.replacement)throw new Error;n.set(i,t.replacement)}return e}catch{throw new Error(w)}}function F(o,e=!1){let n;try{if(e){ne(se(o),{recursive:!0,mode:448});try{let r=j(o,O.O_WRONLY|O.O_CREAT|O.O_EXCL,384);try{ie(r,JSON.stringify(C,null,2)+`
3
+ `)}finally{J(r)}}catch(r){if(r.code!=="EEXIST")throw r}}n=j(o,O.O_RDONLY|O.O_NOFOLLOW|O.O_NONBLOCK);let t=re(n);if(!t.isFile()||t.nlink!==1||t.size>1024*1024||process.getuid&&t.uid!==process.getuid())throw new Error;process.platform!=="win32"&&te(n,384);let i=oe(n,"utf8");if(Buffer.byteLength(i)>1024*1024)throw new Error;return{config:I(i),raw:i}}catch{throw new Error(w)}finally{n!==void 0&&J(n)}}var A=/^protecter(?:\.[a-f0-9]{32})?\.json$/;function de(o){return new Promise((e,n)=>{let t=new le(ue(new URL("./validate-worker.mjs",import.meta.url)),{workerData:o,execArgv:[],resourceLimits:{maxOldGenerationSizeMb:64}}),i=!1,r=l=>{i||(i=!0,clearTimeout(s),t.terminate(),l?e():n(new Error(w)))},s=setTimeout(()=>r(!1),2e3);t.once("message",l=>r(l===!0)),t.once("error",()=>r(!1)),t.once("exit",()=>r(!1))})}function ge(o){let e=new Set,n=[];for(let i of o)for(let r of i.sensitiveWords){let s=typeof r=="string"?JSON.stringify(["literal",r,null]):r.type==="literal"?JSON.stringify(["literal",r.value,r.replacement??null]):JSON.stringify(["regex",r.pattern,[...new Set((r.flags??"")+"g")].sort().join(""),r.replacement??null]);e.has(s)||(e.add(s),n.push(r))}let t=JSON.stringify({version:1,sensitiveWords:n});if(Buffer.byteLength(t)>1024*1024)throw new Error(w);return I(t)}function W(o){let e=u.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 T(o,e){let n=k(o,e),t=W(n),i=F(n);if(W(n)!==t)throw new Error(w);return{name:e,...i,identity:t,digest:ce("sha256").update(i.raw).digest("hex")}}function x(o,e){let n=T(o,e.name);if(n.identity!==e.identity||n.digest!==e.digest)throw new Error(w)}function P(o){return u.readdirSync(o).filter(e=>A.test(e)).sort()}function U(o){if(process.platform==="win32")return;let e=u.openSync(o,u.constants.O_RDONLY);try{u.fsyncSync(e)}finally{u.closeSync(e)}}async function z(o,e,n={}){if(!/^[a-f0-9]{32}$/.test(e))throw new Error(w);let t=ae(o),i=`protecter.${e}.json`,r=k(t,i);u.mkdirSync(t,{recursive:!0,mode:448});let s=k(t,".protecter-migration.lock"),l=Date.now()+(n.lockTimeoutMs??5e3);for(;;)try{u.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=l)throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");await new Promise(g=>setTimeout(g,50))}let a;try{let d=P(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=>T(t,h)),p=ge(c.length?c.map(h=>h.config):[C]);if(await de([p]),c.length===1&&c[0].name===i){if(x(t,c[0]),JSON.stringify(P(t))!==JSON.stringify(d))throw new Error(w);return{path:r,config:c[0].config,raw:c[0].raw,sourceRaws:[c[0].raw],migrated:0}}let m=JSON.stringify(p,null,2)+`
4
+ `;if(Buffer.byteLength(m)>1024*1024)throw new Error(w);a=k(t,`.protecter-migration.${fe()}.tmp`);let y=u.openSync(a,u.constants.O_WRONLY|u.constants.O_CREAT|u.constants.O_EXCL,384);try{u.writeFileSync(y,m),u.fsyncSync(y)}finally{u.closeSync(y)}if(n.checkpoint?.("beforeCommit",r),JSON.stringify(P(t))!==JSON.stringify(d))throw new Error(w);for(let h of c)x(t,h);u.renameSync(a,r),a=void 0,U(t);let E=T(t,i);if(E.raw!==m)throw new Error(w);n.checkpoint?.("afterCommit",r);for(let h of c)h.name!==i&&(n.checkpoint?.("beforeDelete",k(t,h.name)),x(t,E),x(t,h),u.unlinkSync(k(t,h.name)));U(t);let R=[i];if(JSON.stringify(P(t))!==JSON.stringify(R))throw new Error(w);return x(t,E),{path:r,config:p,raw:m,sourceRaws:[...new Set([...c.map(h=>h.raw),m])],migrated:c.filter(h=>h.name!==i).length}}catch{throw new Error(w)}finally{if(a)try{u.unlinkSync(a)}catch{}u.rmdirSync(s)}}import{createHash as pe}from"node:crypto";import{readFileSync as me}from"node:fs";import{execFileSync as B}from"node:child_process";var G="SPI Protecter: machine ID unavailable / 无法获取机器标识。";function $(o){let e=o.trim().toLowerCase().replace(/-/g,"");if(!/^[a-f0-9]{32}$/.test(e)||/^0+$/.test(e))throw new Error(G);return pe("sha256").update(`pi-information-protecter:v1:${e}`).digest("hex").slice(0,32)}function q(){try{if(process.platform==="linux")return $(me("/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 $(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 $(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 v="SPI Protecter: audit unavailable; request blocked / 审计日志不可用,请求已阻止。",we=32*1024*1024,ye=8*1024*1024;function Se(o){let e=n=>String(n).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 n=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(n);if(!t.isFile()||t.nlink!==1||process.getuid&&t.uid!==process.getuid())throw new Error;return process.platform!=="win32"&&f.fchmodSync(n,384),n}catch{throw f.closeSync(n),new Error(v)}}async function X(o,e,n){if(!n.length)return;let t=new Date,i=he(),r=Buffer.from(n.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(r.length>ye)throw new Error(v);let s=`${o}.lock`,l=Date.now()+2e3;for(;;)try{f.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=l)throw new Error(v);await new Promise(g=>setTimeout(g,25))}let a;try{a=H(o,!0);let d=f.fstatSync(a).size;if(d+r.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<r.length;){let c=f.writeSync(a,r,g,r.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(v)}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(v);let n;try{try{n=H(o,!1)}catch(p){if(p.code==="ENOENT")return{records:[],truncated:!1};throw p}let t=f.fstatSync(n).size,i=Math.max(0,t-1024*1024),r=Buffer.alloc(t-i),s=f.readSync(n,r,0,r.length,i),l=r.subarray(0,s).toString("utf8");if(i){let p=l.indexOf(`
7
+ `);l=p<0?"":l.slice(p+1)}let a=l.split(`
8
+ `),d=a.pop()!=="",g=[],c=!1;for(let p of a.slice(-e))try{let m=JSON.parse(p);if(m.version!==1||typeof m.time!="string"||typeof m.provider!="string"||typeof m.original!="string"||typeof m.replacement!="string"||typeof m.requestId!="string"||typeof m.timezoneOffset!="number")throw new Error;g.push(m)}catch{c=!0}return{records:g,truncated:i>0||a.length>e||d||c}}catch{throw new Error(v)}finally{n!==void 0&&f.closeSync(n)}}var S="SPI Protecter: request cleared; check config, audit log, scan limits or attachments / 请求已清空,请检查配置、审计日志、扫描限制或附件。",b=class{constructor(e,n=2e3,t=q){this.configDir=e;this.timeoutMs=n;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,n="unknown"){let t=this.queue.then(()=>this.scan(e,n));return this.queue=t.catch(()=>{}),t}async scan(e,n){if(!this.ready)throw new Error(S);let{config:t,sourceRaws:i}=this.snapshot,r=JSON.stringify(e);if(!r||Buffer.byteLength(r)>8*1024*1024)throw new Error(S);let s;try{s=JSON.parse(r)}catch{throw new Error(S)}let l=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 p=y=>{if(!g){if(g=!0,clearTimeout(m),this.workers.delete(c),c.terminate(),!y||this.closed){d(new Error(S));return}a(y)}},m=setTimeout(()=>p(),this.timeoutMs);c.once("message",y=>p(y.failed?void 0:y)),c.once("error",()=>p()),c.once("exit",()=>p())});if(this.closed)throw new Error(S);if(await X(this.auditPath,n,l.hits),this.closed)throw new Error(S);for(let[a,d]of l.additions)this.tokens.set(a,d);return l.payload}restoreText(e){let n=r=>r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),t=[...this.tokens.keys()].filter(r=>!/^__PIP_[a-f0-9]{48}__$/.test(r)),i=new RegExp(["__PIP_[a-f0-9]{48}__",...t.sort((r,s)=>s.length-r.length).map(n)].join("|"),"g");return e.replace(i,r=>this.tokens.get(r)??r)}restore(e,n=0){if(n>80)throw new Error("protecter: 响应层级过深。");if(typeof e=="string")return this.restoreText(e);if(Array.isArray(e))return e.map(t=>this.restore(t,n+1));if(e&&typeof e=="object"){let t=Object.create(null);for(let[i,r]of Object.entries(e)){let s=this.restoreText(i);if(Object.hasOwn(t,s))throw new Error("protecter: 响应键冲突。");t[s]=this.restore(r,n+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 ke,statSync as K,readdirSync as ve}from"node:fs";import{homedir as Re}from"node:os";import{basename as L,dirname as N,isAbsolute as xe,relative as V,resolve as _}from"node:path";import{fileURLToPath as Ne}from"node:url";function Pe(o,e){let n=o.replace(/^@/,"");return n.startsWith("file://")&&(n=Ne(n)),(n==="~"||n.startsWith("~/"))&&(n=Re()+n.slice(1)),_(e,n)}function M(o){try{return ke(o)}catch{let e=N(o);return e===o?o:_(M(e),V(e,o))}}function be(o,e){let n=V(o,e);return n===""||!n.startsWith("..")&&!xe(n)}function Q(o,e){try{let n=K(o),t=K(e);return n.dev===t.dev&&n.ino===t.ino}catch{return!1}}function Z(o,e,n,t){if(!t)return!0;let i=M(_(t));if(["bash","powershell"].includes(o)){let l=typeof e.command=="string"?e.command:"";if(/protecter(?:\.[a-f0-9]{32})?\.jsonl?|\.protecter-migration/i.test(l)||l.includes(t))return!0}let r=e.path??e.file_path??e.filePath,s=["grep","find","ls"].includes(o);if(typeof r!="string"&&!s)return!1;try{let l=Pe(typeof r=="string"?r:".",n),a=M(l),d=/^protecter\.[a-f0-9]{32}\.jsonl(?:\.lock)?$/,g=ve(N(i)).filter(c=>d.test(c)).some(c=>Q(l,_(N(i),c)));return N(a)===N(i)&&(A.test(L(a))||d.test(L(a))||L(a).startsWith(".protecter-migration"))||a===i||Q(l,t)||g||s&&be(a,i)}catch{return!0}}function Ce(o){let e=new b(_e()),n=/^0\.85\./.test(ee)&&Number(ee.split(".")[2])>=1,t=!1;o.on("session_start",async(i,r)=>{try{if(!n)throw new Error;await e.initialize(),t=!0;let s=e.ruleCount;r.ui.setStatus("protecter",`SPI Protecter · ${s} rules`),s===0&&r.ui.notify("SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。","warning")}catch{t=!1,r.ui.notify(S,"error")}}),o.on("before_provider_request",async(i,r)=>{try{if(!n)throw new Error;let s=await e.redact(i.payload,r.model?.provider??"unknown");return t=!0,s}catch{t=!1;try{await r.abort()}catch{}try{r.ui.notify(S,"error")}catch{}return{}}}),o.on("message_end",i=>{if(i.message.role!=="assistant")return;let r=i.message;return{message:{...r,content:r.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),...r.errorMessage?{errorMessage:e.restoreText(r.errorMessage)}:{}}}}),o.registerMarkdownTransformer((i,r)=>r.messageType==="user"?i:e.restoreText(i)),o.on("tool_call",(i,r)=>{if(!t)return{block:!0,reason:"SPI Protecter 尚未就绪。"};let s=e.restore(i.input);for(let l of Object.keys(i.input))delete i.input[l];if(Object.assign(i.input,s),Z(i.toolName,i.input,r.cwd,e.configPath))return{block:!0,reason:"SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。"}}),o.on("session_before_compact",(i,r)=>(r.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,r)=>{let s=i.trim().split(/\s+/).filter(Boolean),l=s[0]??"logs";if(!["status","reload","logs"].includes(l)||l!=="logs"&&s.length>1||s.length>2||s[1]!==void 0&&!/^(?:[1-9]\d?|100)$/.test(s[1])){r.ui.notify("Usage / 用法: /protecter [logs [1-100]|status|reload]. No secrets in arguments / 不要在参数中输入秘密。","info");return}if(l==="logs"){if(r.mode!=="tui"){r.ui.notify("Local TUI required / 请在本地 TUI 查看记录。","warning");return}try{let{records:a,truncated:d}=e.auditRecords(Number(s[1]??20));if(!a.length){r.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)}`),p=await r.ui.select(`Protection records / 保护记录${d?" (limited tail / 有限尾部)":""}`,c),m=p===void 0?-1:c.indexOf(p);if(m<0||!await r.ui.confirm("Sensitive plaintext / 敏感明文","Reveal locally? Never share this view / 确认本地显示?请勿分享此界面。"))return;let y=g(a[m]);await r.ui.editor("Local preview; edits discarded / 本地预览,编辑不保存",y.length>2e4?y.slice(0,2e4)+`
9
+ [Preview truncated / 预览截断]`:y)}catch{r.ui.notify("Cannot read audit records / 无法读取审计记录。","error")}return}if(l==="reload"){await r.waitForIdle(),await r.reload();return}r.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{Ce as default};
@@ -1,2 +1,2 @@
1
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;
2
+ import{parentPort as b,workerData as T}from"node:worker_threads";import{randomBytes as $}from"node:crypto";try{let S=function(t,r){let e=p.get(t);if(e){if(r!==void 0&&e!==r)throw new Error;return e}if(r!==void 0){if(c.size>=5e4)throw new Error;if(c.has(r)&&c.get(r)!==t)throw new Error;if(r.includes(t))throw new Error;return p.set(t,r),c.set(r,t),u.push([r,t]),r}if(c.size>=5e4)throw new Error;do e=`__PIP_${$(24).toString("hex")}__`;while(c.has(e)||N.includes(e));return p.set(t,e),c.set(e,t),u.push([e,t]),e},m=function(t){let r=[];for(let o of w)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,o.replacement]),++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,o.replacement]),++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);if(n&&o[0]<n[1]){if(n[2]!==void 0||o[2]!==void 0){if(n[0]!==o[0]||n[1]!==o[1]||n[2]!==void 0&&o[2]!==void 0&&n[2]!==o[2])throw new Error;n[2]??=o[2]}n[1]=Math.max(n[1],o[1])}else e.push([...o])}let s="",f=0;for(let[o,n,i]of e){let a=S(t.slice(o,n),i);x.add(a),s+=t.slice(f,o)+a,f=n}return s+t.slice(f)},h=function(t){let r="",e=0,s=i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),f=[...c.keys()].filter(i=>!/^__PIP_[a-f0-9]{48}__$/.test(i)),o=new RegExp(["__PIP_[a-f0-9]{48}__",...f.sort((i,a)=>a.length-i.length).map(s)].join("|"),"g"),n=[...t.matchAll(o)].filter(i=>c.has(i[0]));for(let i of w)if(!(!i.regex||!f.length)){i.regex.lastIndex=0;for(let a of t.matchAll(i.regex)){if(++y>1e5||!a[0].length)throw new Error;if(n.some(g=>f.includes(g[0])&&a.index<g.index+g[0].length&&a.index+a[0].length>g.index))throw new Error}}for(let i of n)c.has(i[0])&&(r+=m(t.slice(e,i.index))+i[0],e=i.index+i[0].length);return r+m(t.slice(e))},d=function(t,r=0){if(++M>2e5||r>80)throw new Error;if(typeof t=="string"){if(O.some(e=>t.includes(e)))return h(t);if(/^\s*[[{]/.test(t)){let e;try{e=JSON.parse(t)}catch{}if(e&&typeof e=="object"){let s=JSON.stringify(d(e,r+1)),f=h(s);return f===s?s:(JSON.parse(f),f)}}return h(t)}if(typeof t=="number"){let e=h(String(t));return e===String(t)?t:e}if(Array.isArray(t))return t.map(e=>d(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,f]of Object.entries(t)){let o=h(s);if(Object.hasOwn(e,o))throw new Error;e[o]=d(f,r+1)}return e}if(t===null||typeof t=="boolean")return t;throw new Error},l=function(t,r,e=0){if(e>80)throw new Error;if(typeof t=="string"){if(t.includes(r))return!0;let s;try{s=JSON.parse(t)}catch{return!1}return s&&typeof s=="object"?l(s,r,e+1):!1}return Array.isArray(t)?t.some(s=>l(s,r,e+1)):t&&typeof t=="object"?Object.entries(t).some(([s,f])=>s.includes(r)||l(f,r,e+1)):!1};I=S,k=m,D=h,R=d,z=l;let{payload:_,rules:A,entries:E,configRaws:J}=T,p=new Map(E.map(([t,r])=>[r,t])),c=new Map(E),u=[],x=new Set,w=A.map(t=>typeof t=="string"?{literal:t}:t.type==="literal"?{literal:t.value,replacement:t.replacement}:{replacement:t.replacement,regex:new RegExp(t.pattern,[...new Set((t.flags??"")+"g")].join(""))});for(let t of p.keys())w.push({literal:t});let O=[...new Set(J.flatMap(t=>[t,JSON.stringify(JSON.parse(t))]))];for(let t of O)w.push({literal:t});let N=JSON.stringify(_),y=0,M=0,j=d(_),P=[...x].filter(t=>l(j,t)).map(t=>[t,c.get(t)]);b.postMessage({payload:j,additions:u,hits:P})}catch{b.postMessage({failed:!0})}var I,k,D,R,z;
@@ -1,2 +1,2 @@
1
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)}
2
+ import{parentPort as i,workerData as c}from"node:worker_threads";try{for(let f of c){let n=f.sensitiveWords,o=n.filter(e=>typeof e=="object"&&e.replacement!==void 0);for(let e of n)if(typeof e=="object"&&e.type==="regex"&&new RegExp(e.pattern,e.flags??"").test(""))throw new Error;for(let e=0;e<o.length;e++){let r=o[e].replacement;for(let t of n)if(typeof t=="string"?r.includes(t)||t.includes(r):t.type==="literal"?r.includes(t.value)||t.value.includes(r):new RegExp(t.pattern,t.flags??"").test(r))throw new Error;for(let t=0;t<e;t++){let s=o[t];if(!r.includes(s.replacement)&&!s.replacement.includes(r))continue;if(!(JSON.stringify(o[e])===JSON.stringify(s)))throw new Error}}}i.postMessage(!0)}catch{i.postMessage(!1)}
package/docs/MIGRATION.md CHANGED
@@ -20,6 +20,10 @@ Current-target rules come first, then old files in filename order. Strings and e
20
20
 
21
21
  当前目标规则优先,旧文件按名称顺序合并。字符串和等价字面对象按精确值去重;正则按模式和含隐式 `g` 的规范化标志去重。大小写、空白、Unicode 和不同正则语义保留。合并并格式化后仍须满足 1000 条及 1 MiB 限制,不允许截断。
22
22
 
23
+ Fixed `replacement` values are part of rule identity. The same matcher with different targets (including fixed versus random) is a conflict, not an override. Merged targets are checked together in the bounded worker before any write or deletion; unsafe or ambiguous aliases stop migration. Old plugin versions reject this optional field, so upgrade before editing configuration.
24
+
25
+ 固定 `replacement` 是规则身份的一部分。相同匹配器具有不同目标(包括固定与随机)视为冲突,而非覆盖。合并后的目标会在限时 worker 中联合检查,通过后才写入或删除;不安全或歧义别名会停止迁移。旧插件版本拒绝该字段,需先升级再修改配置。
26
+
23
27
  ## Commit and cleanup / 提交与清理
24
28
 
25
29
  1. Acquire the directory-wide `.protecter-migration.lock` using exclusive directory creation; wait up to 5 seconds for cooperating processes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-information-protecter",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Sensitive Personal Information (SPI) Protecter — local reversible privacy redaction for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,6 +2,8 @@
2
2
  "version": 1,
3
3
  "sensitiveWords": [
4
4
  "张三",
5
+ { "type": "literal", "value": "Apple Inc.", "replacement": "Alphabet Inc." },
6
+ { "type": "literal", "value": "example-private-key-123", "replacement": "MyPrivateKey" },
5
7
  { "type": "literal", "value": "replace-with-your-real-password" },
6
8
  { "type": "regex", "pattern": "https?://[^\\s\\\"<>]+", "flags": "i" },
7
9
  { "type": "regex", "pattern": "(?<!\\d)1[3-9]\\d{9}(?!\\d)" },