pi-information-protecter 0.4.1 → 0.6.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,47 @@
1
1
  # Changelog / 更新日志
2
2
 
3
+ ## 0.6.0 — Shape-preserving replacements and single-language messages / 同形替换与单语言消息
4
+
5
+ ### Changed / 变更
6
+
7
+ - **Breaking:** replace matches with random values of the same shape instead of `__PIP_<hex>__` tokens, preserving length, case pattern, digits and punctuation positions so tool schemas, URLs, emails and embedded JSON stay valid.
8
+ **破坏性变更:**命中内容改为同形随机值而非占位符,保留长度、大小写形态、数字和标点位置,使工具结构、URL、邮箱和内嵌 JSON 仍然有效。
9
+ - Keep numeric JSON values as numbers with the same digit count and safe-integer range, fixing provider errors such as `'9007199254740991' is not of type 'number'`.
10
+ 数值型 JSON 字段保持数字类型、位数和安全整数范围,修复类似 `'9007199254740991' is not of type 'number'` 的提供商错误。
11
+ - Replace characters within their own writing system, covering Latin with diacritics, Vietnamese, Greek, Cyrillic, Hebrew, Arabic, Thai, Hiragana, Katakana, Hangul and CJK; punctuation, whitespace, emoji and unknown code points stay unchanged.
12
+ 在各自文字体系内替换字符,覆盖带重音拉丁字母、越南语、希腊语、西里尔语、希伯来语、阿拉伯语、泰语、平假名、片假名、谚文及汉字;标点、空白、表情和未知码位保持不变。
13
+ - **Breaking:** render every notification, prompt and diagnostic in one language selected from `PI_PROTECTER_LANG`, `LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE` or the runtime locale, instead of pairing English and Chinese in the same string; codes, stages and numeric details stay language-neutral.
14
+ **破坏性变更:**通知、提示和诊断改为单一语言,按上述环境变量或运行时区域选择,不再在同一字符串中并列中英文;错误码、阶段和数值细节保持语言无关。
15
+ - Format counts with `Intl.PluralRules` and `Intl.NumberFormat` so quantities read naturally in each language.
16
+ 使用 `Intl.PluralRules` 和 `Intl.NumberFormat` 格式化数量,使各语言表达自然。
17
+
18
+ ### Added / 新增
19
+
20
+ - Add `SCAN_NUMBER_UNSAFE` and `SCAN_UNIQUE_FAILED` diagnostics so numbers that cannot round-trip and matches with no unique replacement fail closed instead of corrupting a request.
21
+ 新增两个诊断码,使无法精确往返的数值和无唯一替换值的匹配拒绝放行,而不破坏请求。
22
+ - Add `RESTORE_COMPLEXITY` and `RESTORE_KEY_COLLISION` diagnostics for oversized or ambiguous restoration input.
23
+ 为过深或歧义的还原输入新增两个诊断码。
24
+ - Add regressions for numeric type preservation, per-script shapes, punctuation and emoji layout, collision avoidance, fail-closed paths and locale selection.
25
+ 新增回归,覆盖数值类型保持、各文字体系同形、标点与表情布局、冲突避免、失败拒绝路径及语言选择。
26
+
27
+ ### Security / 安全
28
+
29
+ - Document that shape-preserving values leak length, character class and punctuation structure, look like plausible data, and may be restored when a model independently emits the same short string; prefer long, distinctive targets.
30
+ 文档说明同形值会泄露长度、字符类别和标点结构,看似真实数据,且模型自行输出相同短字符串时可能被还原,应优先使用较长且有辨识度的目标。
31
+
32
+ ## 0.5.0 — Actionable block diagnostics / 可定位的拦截诊断
33
+
34
+ - Replace the single generic block message with a stable code, failing stage, reason and suggested action across initialization, configuration, migration, request, scan, worker and audit paths.
35
+ 将单一笼统拦截提示替换为稳定错误码、失败阶段、原因和处理建议,覆盖初始化、配置、迁移、请求、扫描、worker 和审计路径。
36
+ - Report rule and node coordinates, exceeded limits and OS `errno` where known, including codes propagated from both bounded workers.
37
+ 在已知时输出规则及节点定位、超限阈值和系统错误码,包括两个限时 worker 传递的错误码。
38
+ - Reuse the initialization cause for later blocked requests and tool calls, and repeat the last recorded block through `/protecter status`.
39
+ 后续被拦截的请求和工具调用复用初始化原因,并通过状态命令重新展示最近一次拦截。
40
+ - Keep diagnostics free of request text, matched originals, replacement values, configuration content, file paths and raw OS messages; requests still fail closed.
41
+ 诊断不包含请求文本、命中原文、替换值、配置内容、文件路径和原始系统消息;请求仍保持失败不放行。
42
+ - Add diagnostics regressions for sanitization, hostile worker payloads, per-stage codes and rule coordinates.
43
+ 新增诊断回归,覆盖净化、恶意 worker 数据、各阶段错误码和规则定位。
44
+
3
45
  ## 0.4.1 — Safe audit clearing / 安全审计清理
4
46
 
5
47
  - Add `/protecter logs clear`, restricted to local TUI with explicit confirmation and idle waiting; cancellation and non-TUI modes never clear records.
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.4.1
17
+ pi install npm:pi-information-protecter@0.5.0
18
18
 
19
19
  # Alternatively, build and install from this repository without copying it.
20
20
  npm ci
@@ -82,6 +82,31 @@ Targets are literal text, not replacement templates: `$1` and `$&` are not expan
82
82
 
83
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
84
 
85
+ ### Shape-preserving replacements
86
+
87
+ Since 0.6.0 a matched value is replaced by a random value of the **same shape** instead of an opaque token, so requests stay schema-valid:
88
+
89
+ | Original | Replacement | Preserved |
90
+ | --- | --- | --- |
91
+ | `9007199254740991` (number) | `2844008842109708` (number) | JSON type, digit count, safe-integer range |
92
+ | `13800138000` | `06864831332` | length, digits only |
93
+ | `Alex.Morgan@Example.COM` | `Ufqz.Vwtudb@Rjdnpxe.HGZ` | length, case pattern, punctuation positions |
94
+ | `张三` | `丅欕` | CJK script, character count |
95
+ | `Nguyễn Văn Tèo` | `Ufefṉg Zżt Sàb` | Latin with diacritics, word boundaries |
96
+ | `ひらがな` | `ゅせはっ` | Hiragana |
97
+
98
+ This fixes provider errors such as `'9007199254740991' is not of type 'number'`, which happened when a numeric field was replaced by a token string. Numeric JSON values stay numbers, keep their digit count and remain safe integers. If a partial match cannot round-trip as a number, the request is blocked with `SCAN_NUMBER_UNSAFE` instead of sending an invalid type.
99
+
100
+ Letters and digits are replaced inside their own writing system: ASCII, Latin-1, Latin Extended-A, Latin Extended Additional (including Vietnamese), Greek, Cyrillic, Hebrew, Arabic, Thai, Hiragana, Katakana, Hangul and CJK. Punctuation, whitespace, emoji, symbols and code points outside these ranges are kept unchanged, which is what keeps URLs, emails and embedded JSON parseable.
101
+
102
+ **This is a deliberate trade-off.** An opaque token revealed only that a redaction happened; a shape-preserving value also reveals length, character classes and punctuation structure, and it looks like plausible real data to the model. Text in unsupported scripts is preserved as-is, so a rule matching such text may leave part of it visible.
103
+
104
+ Replacements are checked so restoration stays exact: a candidate is rejected if it equals the original, is already mapped, or already appears anywhere in the same request. When no unique candidate can be produced, typically for very short matches, the request is blocked with `SCAN_UNIQUE_FAILED`. Because a shaped value is indistinguishable from ordinary content, a model that independently emits the same short string will have it restored to the original, so prefer rules that match longer, distinctive values.
105
+
106
+ ### Message language
107
+
108
+ All notifications, prompts and diagnostics render in a single language, chosen from the environment: `PI_PROTECTER_LANG`, then `LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`, then the runtime locale. `zh*` selects Simplified Chinese; anything else falls back to English. Set `PI_PROTECTER_LANG=en` or `PI_PROTECTER_LANG=zh` to override. Error codes, stages and numeric details stay language-neutral so they can be searched and reported.
109
+
85
110
  ### Multi-turn consistency and prompt caching
86
111
 
87
112
  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.
@@ -98,6 +123,26 @@ Use `/protecter logs clear` to empty only the current machine's audit log. Local
98
123
 
99
124
  **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.
100
125
 
126
+ ## Diagnosing a blocked request
127
+
128
+ When a request is blocked, the notification names the failing stage, a stable code and a suggested action instead of one generic message:
129
+
130
+ ```text
131
+ SPI Protecter blocked this request
132
+ code=CONFIG_EMPTY_MATCH stage=configuration rule=1
133
+ Reason: Regex matches an empty string
134
+ Action: Require a non-empty match in the numbered rule
135
+ Details: /protecter status
136
+ ```
137
+
138
+ Stages are `initialization`, `configuration`, `migration`, `request`, `scan`, `worker`, `audit` and `internal`. Coordinates appear only when known: `rule` and `otherRule` are 1-based indexes into `sensitiveWords`, `node` counts scanned payload nodes, `bytes`/`limit`/`timeoutMs` report the exceeded limit, and `errno` is the OS error code.
139
+
140
+ Common codes include `CONFIG_JSON`, `CONFIG_SCHEMA`, `CONFIG_REGEX`, `CONFIG_TARGET`, `CONFIG_CONFLICT`, `CONFIG_EMPTY_MATCH`, `CONFIG_SENSITIVE_TARGET`, `CONFIG_ALIAS_CONFLICT`, `CONFIG_UNSAFE`, `MIGRATION_LOCKED`, `MIGRATION_CHANGED`, `PAYLOAD_JSON`, `PAYLOAD_SIZE`, `SCAN_TIMEOUT`, `SCAN_MATCH_LIMIT`, `SCAN_COMPLEXITY`, `SCAN_ATTACHMENT`, `SCAN_JSON_REWRITE`, `SCAN_NUMBER_UNSAFE`, `SCAN_UNIQUE_FAILED`, `FIXED_OVERLAP`, `FIXED_ALIAS_REUSED`, `AUDIT_FULL`, `AUDIT_PARTIAL`, `AUDIT_LOCKED`, `AUDIT_UNSAFE` and `WORKER_FAILED`.
141
+
142
+ Notifications can scroll away, so `/protecter status` repeats the last recorded block for the session, and a tool blocked while protection is unavailable reports the same cause. Requests still fail closed: the outgoing payload is replaced with `{}`.
143
+
144
+ **Diagnostics never include request text, matched originals, replacement values, configuration content, file paths or raw OS messages.** Only catalog codes, non-negative integer coordinates and an allow-listed `errno` are emitted, so they are safe to paste into an issue. Because matched content is excluded, locating an offending rule may still require local inspection using the reported rule index.
145
+
101
146
  ## How it works
102
147
 
103
148
  ```text
@@ -105,13 +150,13 @@ Input / history / tools / system prompt / tool definitions
105
150
  ↓
106
151
  before_provider_request: final JSON text scan
107
152
  ↓
108
- __PIP_<192-bit random hex>__ → LLM gateway
153
+ shape-preserving random value → LLM gateway
109
154
  ↓
110
155
  Local response and tool-argument restoration
111
156
  ```
112
157
 
113
158
  1. Scan JSON strings, keys and numbers, including decoded JSON-string tool arguments. Local user input and original history are unchanged.
114
- 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.
159
+ 2. Generate a shape-preserving replacement with `crypto.randomBytes`. Runtime mappings stay in memory and are not restored across restarts. **Since 0.3.0, successful original/replacement pairs are also persisted in the private audit log.** They are never injected into configuration, session custom entries or model prompts.
115
160
  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.
116
161
  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.**
117
162
  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.
@@ -165,7 +210,7 @@ API references: [Pi Extensions](https://pi.dev/docs/latest/extensions), local 0.
165
210
 
166
211
  ```bash
167
212
  # 安装固定 npm 版本。
168
- pi install npm:pi-information-protecter@0.4.1
213
+ pi install npm:pi-information-protecter@0.5.0
169
214
 
170
215
  # 或在本仓库构建后本地安装,不复制仓库。
171
216
  npm ci
@@ -233,6 +278,31 @@ pi -e ./src/index.ts
233
278
 
234
279
  为保持可逆保护,不同规则复用相同目标、目标互为子串、目标包含配置敏感值、使用保留 `__PIP_` 名称或定义冲突时拒绝加载。固定正则在同一实例中只能表示一种不同的匹配原文,第二种原文会导致请求被拒绝。涉及固定规则的部分重叠匹配也拒绝,不静默选择规则或泄漏后缀;纯随机规则重叠仍合并。迁移保留固定目标,遇到冲突停止且不删除原文件。修改后请重载;旧插件版本不支持此字段。
235
280
 
281
+ ### 同形替换
282
+
283
+ 从 0.6.0 起,命中内容不再替换为不透明占位符,而是替换为**同形**随机值,使请求保持结构有效:
284
+
285
+ | 原文 | 替换值 | 保留特征 |
286
+ | --- | --- | --- |
287
+ | `9007199254740991`(数字) | `2844008842109708`(数字) | JSON 类型、位数、安全整数范围 |
288
+ | `13800138000` | `06864831332` | 长度、纯数字 |
289
+ | `Alex.Morgan@Example.COM` | `Ufqz.Vwtudb@Rjdnpxe.HGZ` | 长度、大小写形态、标点位置 |
290
+ | `张三` | `丅欕` | 汉字体系、字数 |
291
+ | `Nguyễn Văn Tèo` | `Ufefṉg Zżt Sàb` | 带重音拉丁字母、词边界 |
292
+ | `ひらがな` | `ゅせはっ` | 平假名 |
293
+
294
+ 这修复了类似 `'9007199254740991' is not of type 'number'` 的提供商错误:过去数值字段会被替换成字符串占位符。现在数值仍为数字,位数不变并保持安全整数;若部分匹配无法作为数字精确往返,则以 `SCAN_NUMBER_UNSAFE` 拦截,而不发送错误类型。
295
+
296
+ 字母和数字在各自文字体系内替换:ASCII、Latin-1、Latin Extended-A、Latin Extended Additional(含越南语)、希腊语、西里尔语、希伯来语、阿拉伯语、泰语、平假名、片假名、谚文及汉字。标点、空白、表情、符号以及上述范围之外的码位保持不变,这正是 URL、邮箱和内嵌 JSON 仍可解析的原因。
297
+
298
+ **这是有意为之的权衡。** 不透明占位符仅暴露“发生了脱敏”;同形值还会暴露长度、字符类别和标点结构,并且在模型看来像真实数据。不支持的文字体系会原样保留,因此匹配这类文本的规则可能残留部分可见内容。
299
+
300
+ 为保证还原精确,候选值会被校验:与原文相同、已被占用、或已出现在同一请求中的候选值会被拒绝。无法生成唯一值时(通常是极短匹配),以 `SCAN_UNIQUE_FAILED` 拦截请求。由于同形值与普通内容难以区分,若模型自行输出了相同的短字符串,它会被还原为原文,因此应优先匹配更长、更有辨识度的值。
301
+
302
+ ### 消息语言
303
+
304
+ 所有通知、提示和诊断仅以**单一语言**呈现,语言按环境变量依次选取:`PI_PROTECTER_LANG`、`LC_ALL`、`LC_MESSAGES`、`LANG`、`LANGUAGE`,最后是运行时区域。`zh*` 选择简体中文,其余回退英文。可设置 `PI_PROTECTER_LANG=en` 或 `PI_PROTECTER_LANG=zh` 覆盖。错误码、阶段和数值细节保持语言无关,便于搜索和反馈。
305
+
236
306
  ### 多轮一致性与提示词缓存
237
307
 
238
308
  随机占位符按精确原文首次生成,在同一扩展实例内持续复用,包括本地回复还原后的再次脱敏;新建扫描 worker 不会重置映射。这有助于前缀稳定,但不保证提供商 KV Cache 命中,模型、工具、消息顺序、缓存有效期和路由也有影响。重载、进程重启和会话实例替换会清除映射;不同进程不共享映射,审计时间和请求 ID 不进入模型请求体。
@@ -249,6 +319,26 @@ pi -e ./src/index.ts
249
319
 
250
320
  **日志包含真实秘密及可逆映射。** Unix 权限为 0600,拒绝符号链接、硬链接及他人文件。工具防护覆盖当前和旧机器哈希日志及别名,但不是沙箱。日志不加密、不自动轮转、不随配置迁移,也不用于恢复映射。请停止全部 Pi 进程后再本地归档、删除日志或检查残留 `.jsonl.lock`。读取限最后 1 MiB、最多 100 条,每条预览最多 20,000 字符;单日志上限 32 MiB、请求批次 8 MiB。日志满、不可写、尾部残缺或锁占用时,命中请求会被阻止,不静默漏记。扫描失败不新增记录,硬崩溃可能遗留部分批次,需本地修复。
251
321
 
322
+ ## 拦截原因定位
323
+
324
+ 请求被拦截时,提示不再是单一笼统信息,而是给出失败阶段、稳定错误码和处理建议:
325
+
326
+ ```text
327
+ SPI Protecter 已阻止该请求
328
+ code=CONFIG_EMPTY_MATCH stage=configuration rule=1
329
+ 原因:正则匹配空字符串
330
+ 处理:修改指定规则,要求非空匹配
331
+ 详情:/protecter status
332
+ ```
333
+
334
+ 阶段包括初始化、配置、迁移、请求、扇描、worker、审计和内部错误。定位信息仅在已知时输出:`rule` 和 `otherRule` 是敏感词数组中从 1 开始的序号,`node` 为已扫描节点数,`bytes`、`limit`、`timeoutMs` 说明超限情况,`errno` 为系统错误码。
335
+
336
+ 常见错误码包括 `CONFIG_JSON`、`CONFIG_SCHEMA`、`CONFIG_REGEX`、`CONFIG_TARGET`、`CONFIG_CONFLICT`、`CONFIG_EMPTY_MATCH`、`CONFIG_SENSITIVE_TARGET`、`CONFIG_ALIAS_CONFLICT`、`CONFIG_UNSAFE`、`MIGRATION_LOCKED`、`MIGRATION_CHANGED`、`PAYLOAD_JSON`、`PAYLOAD_SIZE`、`SCAN_TIMEOUT`、`SCAN_MATCH_LIMIT`、`SCAN_COMPLEXITY`、`SCAN_ATTACHMENT`、`SCAN_JSON_REWRITE`、`SCAN_NUMBER_UNSAFE`、`SCAN_UNIQUE_FAILED`、`FIXED_OVERLAP`、`FIXED_ALIAS_REUSED`、`AUDIT_FULL`、`AUDIT_PARTIAL`、`AUDIT_LOCKED`、`AUDIT_UNSAFE` 和 `WORKER_FAILED`。
337
+
338
+ 通知可能消失,因此 `/protecter status` 会重新展示本会话最近一次拦截;防护不可用时被拦截的工具也会报告同一原因。请求仍然失败不放行:出站请求体会被替换为 `{}`。
339
+
340
+ **诊断信息不包含请求文本、命中原文、替换值、配置内容、文件路径或原始系统消息。** 仅输出目录内错误码、非负整数定位和白名单 `errno`,因此可安全粘贴到问题报告。也因为不包含命中内容,最终定位问题规则时,仍需根据规则序号在本地查看。
341
+
252
342
  ## 工作方式
253
343
 
254
344
  ```text
@@ -256,13 +346,13 @@ pi -e ./src/index.ts
256
346
  ↓
257
347
  before_provider_request:最终 JSON 文本扫描
258
348
  ↓
259
- __PIP_<192-bit 随机十六进制>__ → LLM 网关
349
+ 同形随机值 → LLM 网关
260
350
  ↓
261
351
  本地回复和工具参数还原
262
352
  ```
263
353
 
264
354
  1. 扫描 JSON 字符串、键和数字,包括解码后的 JSON 字符串工具参数;不修改本地用户输入和历史原文。
265
- 2. 使用 `crypto.randomBytes(24)` 生成占位符。运行时映射保存在内存,不跨重启恢复。**从 0.3.0 起,成功替换的原文和占位符也会持久化到私有审计日志。** 不注入配置、会话自定义条目或模型提示。
355
+ 2. 使用 `crypto.randomBytes` 生成同形替换值。运行时映射保存在内存,不跨重启恢复。**从 0.3.0 起,成功替换的原文和占位符也会持久化到私有审计日志。** 不注入配置、会话自定义条目或模型提示。
266
356
  3. 还原最终 assistant 文本、思考内容和工具参数。TUI 在完整占位符到达后还原,片段可能短暂显示;RPC/JSON 增量仍脱敏,直到最终消息还原。被改写或截断的占位符无法还原。
267
357
  4. 工具执行前还原参数,让合法本地操作使用原值;后续请求再次脱敏。**凭证还原不等于工具授权或防外传。**
268
358
  5. 正则扫描运行于可终止 worker。失败时返回 `{}` 并请求取消,不依赖被 Pi 吞掉的钩子异常;仍可能产生空请求或提供商校验错误,但处理器不返回原始请求体。
package/SECURITY.md CHANGED
@@ -19,12 +19,16 @@ Prevent configured personal information from accidentally entering Pi LLM reques
19
19
 
20
20
  ## Implementation / 实现策略
21
21
 
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 原文与占位符审计。日志属于敏感凭证存储,不是加密归档,禁止上传;退出后仍保留,不用于恢复占位符。
22
+ - Scan outgoing payload text and replace matches with random shape-preserving values. Runtime mappings stay in memory, but version 0.3.0 adds plaintext original/replacement 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 mappings.
23
+ 出站文本使用随机同形值替换,运行映射保存在内存,但 0.3.0 新增私有 JSONL 原文与替换值审计。日志属于敏感凭证存储,不是加密归档,禁止上传;退出后仍保留,不用于恢复映射。
24
+ - Shape-preserving replacements (0.6.0) keep JSON types, length, character class and punctuation structure, so they leak more metadata than opaque tokens and look like plausible real data. Unsupported scripts and symbols are preserved unchanged, so part of a matched value may stay visible. Short matches may be restored when a model independently emits the same string; prefer long, distinctive targets. Numeric values that cannot round-trip and matches with no unique replacement fail closed.
25
+ 同形替换(0.6.0)保留 JSON 类型、长度、字符类别和标点结构,因此比不透明占位符泄露更多元信息,且看似真实数据。不支持的文字体系和符号原样保留,命中值可能部分可见。模型自行输出相同短字符串时可能被还原,应优先使用较长且有辨识度的目标;无法精确往返的数值以及无唯一替换值的匹配均拒绝放行。
24
26
  - 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
27
  日志使用 0600、安全打开检查、协作锁,并在返回脱敏请求前同步落盘;日志失败则拒绝请求。成功表示本地替换而非网关已接收。文件及预览大小受限,用户本地管理保留策略;硬崩溃可能残留锁或不完整记录,同用户权限攻击和终端录屏仍不在防护范围。
26
28
  - `/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
29
  查看器仅限 TUI,展示原文前需确认,不注入模型上下文或会话条目;可信扩展仍可能观察 UI 钩子。出站规则依然使用内存缓存,但命中请求现在会写审计文件。
30
+ - Block diagnostics carry a catalog code, stage, non-negative integer coordinates and an allow-listed OS `errno` only. Rule indexes and node counts are positions, not content. Raw exception messages, request text, matched originals, replacement values, configuration content and file paths are never surfaced, including across the worker boundary, so diagnostics are safe to share. Codes are intentionally coarse, so pinpointing a rule may still need local inspection.
31
+ 拦截诊断仅包含目录错误码、阶段、非负整数定位和白名单系统错误码。规则序号和节点数只是位置,不是内容。不输出原始异常消息、请求文本、命中原文、替换值、配置内容和文件路径,worker 边界同样适用,因此诊断可安全分享;错误码有意保持粗粒度,精确定位规则仍可能需要本地查看。
28
32
  - Limit worker regex scans to 2 seconds, with resource and input limits. Errors exclude original configuration, rules and request text.
29
33
  worker 正则扫描限制为 2 秒,并限制资源和输入;错误不包含原始配置、规则或请求文本。
30
34
  - 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 CHANGED
@@ -1,9 +1,13 @@
1
1
  /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
2
- import{getAgentDir as Ie,VERSION as te}from"@earendil-works/pi-coding-agent";import{Worker as ke}from"node:worker_threads";import{fileURLToPath as ve}from"node:url";import*as u from"node:fs";import{join as v,resolve as fe}from"node:path";import{createHash as ue,randomUUID as le}from"node:crypto";import{Worker as de}from"node:worker_threads";import{fileURLToPath as ge}from"node:url";import{constants as k,closeSync as F,fchmodSync as ne,fstatSync as oe,mkdirSync as ie,openSync as j,readFileSync as se,writeFileSync as ae}from"node:fs";import{dirname as ce}from"node:path";var C={version:1,sensitiveWords:[]},w="SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";function I(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;let n=new Map;for(let r of e.sensitiveWords){if(typeof r=="string"){if(!r||r.length>8192)throw new Error;let t=JSON.stringify(["literal",r]);if(n.has(t)&&n.get(t)!==void 0)throw new Error;n.set(t,void 0);continue}if(!r||typeof r!="object")throw new Error;if(Object.hasOwn(r,"replacement")&&(typeof r.replacement!="string"||!r.replacement.trim()||r.replacement.length>8192||r.replacement.includes("__PIP_")))throw new Error;if(r.type==="literal"){if(Object.keys(r).some(t=>!["type","value","replacement"].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(s=>!["type","pattern","flags","replacement"].includes(s))||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;let o=r.type==="literal"?JSON.stringify(["literal",r.value]):JSON.stringify(["regex",r.pattern,[...new Set((r.flags??"")+"g")].sort().join("")]);if(n.has(o)&&n.get(o)!==r.replacement)throw new Error;n.set(o,r.replacement)}return e}catch{throw new Error(w)}}function W(i,e=!1){let n;try{if(e){ie(ce(i),{recursive:!0,mode:448});try{let t=j(i,k.O_WRONLY|k.O_CREAT|k.O_EXCL,384);try{ae(t,JSON.stringify(C,null,2)+`
3
- `)}finally{F(t)}}catch(t){if(t.code!=="EEXIST")throw t}}n=j(i,k.O_RDONLY|k.O_NOFOLLOW|k.O_NONBLOCK);let r=oe(n);if(!r.isFile()||r.nlink!==1||r.size>1024*1024||process.getuid&&r.uid!==process.getuid())throw new Error;process.platform!=="win32"&&ne(n,384);let o=se(n,"utf8");if(Buffer.byteLength(o)>1024*1024)throw new Error;return{config:I(o),raw:o}}catch{throw new Error(w)}finally{n!==void 0&&F(n)}}var A=/^protecter(?:\.[a-f0-9]{32})?\.json$/;function me(i){return new Promise((e,n)=>{let r=new de(ge(new URL("./validate-worker.mjs",import.meta.url)),{workerData:i,execArgv:[],resourceLimits:{maxOldGenerationSizeMb:64}}),o=!1,t=a=>{o||(o=!0,clearTimeout(s),r.terminate(),a?e():n(new Error(w)))},s=setTimeout(()=>t(!1),2e3);r.once("message",a=>t(a===!0)),r.once("error",()=>t(!1)),r.once("exit",()=>t(!1))})}function pe(i){let e=new Set,n=[];for(let o of i)for(let t of o.sensitiveWords){let s=typeof t=="string"?JSON.stringify(["literal",t,null]):t.type==="literal"?JSON.stringify(["literal",t.value,t.replacement??null]):JSON.stringify(["regex",t.pattern,[...new Set((t.flags??"")+"g")].sort().join(""),t.replacement??null]);e.has(s)||(e.add(s),n.push(t))}let r=JSON.stringify({version:1,sensitiveWords:n});if(Buffer.byteLength(r)>1024*1024)throw new Error(w);return I(r)}function z(i){let e=u.lstatSync(i,{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(i,e){let n=v(i,e),r=z(n),o=W(n);if(z(n)!==r)throw new Error(w);return{name:e,...o,identity:r,digest:ue("sha256").update(o.raw).digest("hex")}}function N(i,e){let n=T(i,e.name);if(n.identity!==e.identity||n.digest!==e.digest)throw new Error(w)}function R(i){return u.readdirSync(i).filter(e=>A.test(e)).sort()}function U(i){if(process.platform==="win32")return;let e=u.openSync(i,u.constants.O_RDONLY);try{u.fsyncSync(e)}finally{u.closeSync(e)}}async function q(i,e,n={}){if(!/^[a-f0-9]{32}$/.test(e))throw new Error(w);let r=fe(i),o=`protecter.${e}.json`,t=v(r,o);u.mkdirSync(r,{recursive:!0,mode:448});let s=v(r,".protecter-migration.lock"),a=Date.now()+(n.lockTimeoutMs??5e3);for(;;)try{u.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=a)throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");await new Promise(p=>setTimeout(p,50))}let f;try{let d=R(r);if(d.length>64)throw new Error(w);let l=[...d].sort((h,J)=>h===o?-1:J===o?1:h.localeCompare(J)).map(h=>T(r,h)),g=pe(l.length?l.map(h=>h.config):[C]);if(await me([g]),l.length===1&&l[0].name===o){if(N(r,l[0]),JSON.stringify(R(r))!==JSON.stringify(d))throw new Error(w);return{path:t,config:l[0].config,raw:l[0].raw,sourceRaws:[l[0].raw],migrated:0}}let m=JSON.stringify(g,null,2)+`
4
- `;if(Buffer.byteLength(m)>1024*1024)throw new Error(w);f=v(r,`.protecter-migration.${le()}.tmp`);let y=u.openSync(f,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",t),JSON.stringify(R(r))!==JSON.stringify(d))throw new Error(w);for(let h of l)N(r,h);u.renameSync(f,t),f=void 0,U(r);let E=T(r,o);if(E.raw!==m)throw new Error(w);n.checkpoint?.("afterCommit",t);for(let h of l)h.name!==o&&(n.checkpoint?.("beforeDelete",v(r,h.name)),N(r,E),N(r,h),u.unlinkSync(v(r,h.name)));U(r);let b=[o];if(JSON.stringify(R(r))!==JSON.stringify(b))throw new Error(w);return N(r,E),{path:t,config:g,raw:m,sourceRaws:[...new Set([...l.map(h=>h.raw),m])],migrated:l.filter(h=>h.name!==o).length}}catch{throw new Error(w)}finally{if(f)try{u.unlinkSync(f)}catch{}u.rmdirSync(s)}}import{createHash as he}from"node:crypto";import{readFileSync as we}from"node:fs";import{execFileSync as B}from"node:child_process";var G="SPI Protecter: machine ID unavailable / 无法获取机器标识。";function $(i){let e=i.trim().toLowerCase().replace(/-/g,"");if(!/^[a-f0-9]{32}$/.test(e)||/^0+$/.test(e))throw new Error(G);return he("sha256").update(`pi-information-protecter:v1:${e}`).digest("hex").slice(0,32)}function H(){try{if(process.platform==="linux")return $(we("/etc/machine-id","utf8"));if(process.platform==="darwin"){let i=B("/usr/sbin/ioreg",["-rd1","-c","IOPlatformExpertDevice"],{encoding:"utf8",timeout:5e3,maxBuffer:1048576,stdio:["ignore","pipe","pipe"]});return $(i.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1]??"")}if(process.platform==="win32"){let i=B("reg.exe",["query","HKLM\\SOFTWARE\\Microsoft\\Cryptography","/v","MachineGuid","/reg:64"],{encoding:"utf8",timeout:5e3,maxBuffer:65536,stdio:["ignore","pipe","pipe"]});return $(i.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1]??"")}}catch{}throw new Error(G)}import*as c from"node:fs";import{randomUUID as ye}from"node:crypto";var O="SPI Protecter: audit unavailable; request blocked / 审计日志不可用,请求已阻止。",Se=32*1024*1024,Ee=8*1024*1024;function Oe(i){let e=n=>String(n).padStart(2,"0");return`${i.getFullYear()}-${e(i.getMonth()+1)}-${e(i.getDate())} ${e(i.getHours())}:${e(i.getMinutes())}:${e(i.getSeconds())}`}function L(i,e,n=e){let r=c.openSync(i,(e?c.constants.O_RDWR|(n?c.constants.O_CREAT:0):c.constants.O_RDONLY)|c.constants.O_NOFOLLOW|c.constants.O_NONBLOCK,384);try{let o=c.fstatSync(r);if(!o.isFile()||o.nlink!==1||process.getuid&&o.uid!==process.getuid())throw new Error;return process.platform!=="win32"&&c.fchmodSync(r,384),r}catch{throw c.closeSync(r),new Error(O)}}async function X(i){let e=`${i}.lock`,n=Date.now()+2e3;for(;;)try{return c.mkdirSync(e,{mode:448}),e}catch(r){if(r.code!=="EEXIST"||Date.now()>=n)throw new Error(O);await new Promise(o=>setTimeout(o,25))}}async function Y(i){let e=await X(i),n;try{try{n=L(i,!0,!1)}catch(o){if(o.code==="ENOENT")return 0;throw o}let r=c.fstatSync(n).size;return c.ftruncateSync(n,0),c.fsyncSync(n),r}catch{throw new Error(O)}finally{try{n!==void 0&&c.closeSync(n)}finally{c.rmdirSync(e)}}}async function K(i,e,n){if(!n.length)return;let r=new Date,o=ye(),t=Buffer.from(n.map(([f,d])=>JSON.stringify({version:1,time:Oe(r),timezoneOffset:-r.getTimezoneOffset(),requestId:o,provider:e||"unknown",original:d,replacement:f})).join(`
2
+ import{getAgentDir as et,VERSION as Ee}from"@earendil-works/pi-coding-agent";import{Worker as Ye}from"node:worker_threads";import{fileURLToPath as We}from"node:url";import*as d from"node:fs";import{join as C,resolve as Ge}from"node:path";import{createHash as Me,randomUUID as ke}from"node:crypto";import{Worker as ve}from"node:worker_threads";import{fileURLToPath as Ue}from"node:url";var z={INTERNAL:["Unexpected local failure","Reload; check the installed package integrity"],NOT_READY:["Protection is not initialized","Run /protecter status for the initialization error, fix it, then /reload"],UNSUPPORTED_PI:["Unsupported Pi version","Use Pi 0.85.1-0.85.x"],MACHINE_ID:["Machine identifier unavailable","Check local OS machine-ID access; no random fallback is used"],CONFIG_JSON:["Invalid configuration JSON","Fix JSON syntax locally, then /reload"],CONFIG_SCHEMA:["Invalid configuration structure or rule","Use version 1, a sensitiveWords array and documented fields; rule shows the 1-based index"],CONFIG_REGEX:["Invalid regex or flags","Fix the numbered rule; flags support gimsu only"],CONFIG_TARGET:["Invalid fixed replacement target","Use a non-blank string under 8192 characters without reserved __PIP_ names"],CONFIG_CONFLICT:["Conflicting rule definitions","Give the same matcher one replacement policy across all configs"],CONFIG_SIZE:["Configuration exceeds size or rule limit","Reduce configuration size or rule count; rules are never truncated"],CONFIG_IO:["Cannot access configuration","Check local permissions, ownership and free space"],CONFIG_UNSAFE:["Unsafe configuration file","Use an owned regular file without symbolic or hard links"],CONFIG_EMPTY_MATCH:["Regex matches an empty string","Require a non-empty match in the numbered rule"],CONFIG_SENSITIVE_TARGET:["Fixed target overlaps a sensitive rule","Choose a target unrelated to every sensitive matcher"],CONFIG_ALIAS_CONFLICT:["Fixed aliases overlap each other","Use distinct aliases that are not substrings of one another"],CONFIG_TIMEOUT:["Rule validation timed out","Simplify regex rules, then /reload"],MIGRATION_LOCKED:["Configuration migration lock is busy","Wait for other Pi processes; inspect a stale lock only after stopping them"],MIGRATION_CHANGED:["Configuration changed during migration","Stop concurrent edits and /reload; source files are preserved"],MIGRATION_LIMIT:["Too many configuration candidates","Consolidate old configuration files locally"],MIGRATION_IO:["Migration file operation failed","Check permissions, free space and leftover files before retrying"],PAYLOAD_JSON:["Request is not serializable JSON","Inspect the provider or another payload-modifying extension; no payload is logged"],PAYLOAD_SIZE:["Request exceeds the size limit","Reduce history or tool output size, or start a new session"],SCAN_TIMEOUT:["Redaction worker timed out","Simplify regex rules or reduce input size"],WORKER_FAILED:["Worker failed to start or exited early","Verify all dist files and the Node version; reinstall if needed"],SCAN_INTERNAL:["Unexpected scan failure","Review recent rule changes and retry with smaller input"],SCAN_NUMBER_UNSAFE:["Replacement would turn a numeric JSON value into an invalid number","Narrow the rule so it does not match inside numeric fields such as timestamps or schema limits"],SCAN_UNIQUE_FAILED:["Could not generate a unique shape-preserving replacement","The matched value is too short; match a longer value or set an explicit replacement"],SCAN_ZERO_WIDTH:["Regex produced a zero-width match","Require a non-empty match in the numbered rule"],SCAN_MATCH_LIMIT:["Too many matches in one request","Narrow overly broad rules or reduce input"],SCAN_MAPPING_LIMIT:["Mapping limit reached","Start a new session or /reload; in-memory mappings reset"],FIXED_MAPPING_CONFLICT:["One original has conflicting replacements","Use one replacement for overlapping matchers, then /reload"],FIXED_ALIAS_REUSED:["Fixed alias already maps to another original","A fixed regex represents one original per instance; use random replacement or split literal rules"],FIXED_SENSITIVE_TARGET:["Replacement contains the matched original","Choose a target without sensitive content"],FIXED_OVERLAP:["Fixed replacement partially overlaps another match","Remove the partial overlap or use random rules; rule and otherRule show both rules"],FIXED_ALIAS_CROSSING:["Regex crosses a learned fixed alias","Narrow the regex or choose a different alias"],SCAN_COMPLEXITY:["Request exceeds depth or node limits","Reduce nested data and tool schema size"],SCAN_JSON_REWRITE:["Replacement breaks embedded JSON syntax","Avoid rules spanning JSON syntax; match field values instead"],SCAN_ATTACHMENT:["Unsupported image, audio, video or file content","Remove attachments or send reviewed text only"],SCAN_KEY_COLLISION:["Replacement creates duplicate object keys","Use distinct targets or avoid matching schema keys"],RESTORE_COMPLEXITY:["Response nesting is too deep to restore","Reduce nested structures or start a new session with /new"],RESTORE_KEY_COLLISION:["Restoring produced duplicate object keys","Use more specific rules or fixed targets that cannot collide with schema keys"],AUDIT_BATCH_LIMIT:["Audit batch exceeds the size limit","Reduce matched content per request"],AUDIT_FULL:["Audit log would exceed the size limit","Review records, then run /protecter logs clear or archive locally"],AUDIT_PARTIAL:["Audit log has an incomplete last line","Inspect locally or run /protecter logs clear; never upload the log"],AUDIT_LOCKED:["Audit lock stayed busy","Wait for writers; stop all Pi processes before inspecting a stale lock"],AUDIT_UNSAFE:["Unsafe audit file","Use an owned regular file without links; checks are not bypassed"],AUDIT_IO:["Audit file operation failed","Check errno, permissions and free space; no path or content is exposed"]},_e={INTERNAL:["未预期的本地错误","重载并检查安装包完整性"],NOT_READY:["防护尚未初始化","查看初始化错误,修复后重载"],UNSUPPORTED_PI:["Pi 版本不受支持","请使用受支持的 Pi 版本"],MACHINE_ID:["无法获取机器标识","检查系统机器标识访问权限,不使用随机回退"],CONFIG_JSON:["配置 JSON 无效","本地修复 JSON 语法后重载"],CONFIG_SCHEMA:["配置结构或规则无效","使用版本1、敏感词数组及文档字段,rule 为规则序号"],CONFIG_REGEX:["正则或标志无效","修复指定规则,标志仅支持 gimsu"],CONFIG_TARGET:["固定替换目标无效","使用非空且少于8192字符的目标,不含保留名称"],CONFIG_CONFLICT:["规则定义冲突","为同一匹配器设置一致的替换策略"],CONFIG_SIZE:["配置超过体积或规则数上限","减少配置体积或规则数,规则不会被截断"],CONFIG_IO:["无法访问配置","检查本地权限、所有者和磁盘空间"],CONFIG_UNSAFE:["配置文件不安全","使用本人所有的普通文件,不含符号或硬链接"],CONFIG_EMPTY_MATCH:["正则匹配空字符串","修改指定规则,要求非空匹配"],CONFIG_SENSITIVE_TARGET:["固定目标与敏感规则重叠","选择与所有敏感匹配器无关的目标"],CONFIG_ALIAS_CONFLICT:["固定别名相互重叠","使用互不包含的不同别名"],CONFIG_TIMEOUT:["规则验证超时","简化正则规则后重载"],MIGRATION_LOCKED:["配置迁移锁被占用","等待其他进程,全部停止后再检查残留锁"],MIGRATION_CHANGED:["迁移期间配置发生变化","停止并发编辑后重载,源文件会保留"],MIGRATION_LIMIT:["候选配置过多","在本地整理旧配置文件"],MIGRATION_IO:["迁移文件操作失败","检查权限、空间和残留文件后重试"],PAYLOAD_JSON:["请求无法序列化为 JSON","检查提供商或其他请求改写扩展,不记录请求内容"],PAYLOAD_SIZE:["请求超过体积上限","减少历史或工具输出,或新建会话"],SCAN_TIMEOUT:["脱敏 worker 超时","简化正则规则或缩小输入"],WORKER_FAILED:["worker 启动失败或提前退出","检查 dist 文件完整性和 Node 版本,必要时重装"],SCAN_INTERNAL:["未预期的扫描错误","检查最近的规则改动并缩小输入"],SCAN_NUMBER_UNSAFE:["替换会把 JSON 数值变成无效数字","缩小规则范围,避免匹配时间戳或结构上限等数值字段"],SCAN_UNIQUE_FAILED:["无法生成唯一的同形替换值","命中内容过短或可选空间过小,请匹配更长内容或设置固定 replacement"],SCAN_ZERO_WIDTH:["正则产生零宽匹配","修改指定规则,要求非空匹配"],SCAN_MATCH_LIMIT:["单次请求匹配次数过多","缩小过宽规则或减少输入"],SCAN_MAPPING_LIMIT:["映射数量达到上限","新建会话或重载,内存映射将重置"],FIXED_MAPPING_CONFLICT:["同一原文存在冲突替换","为重叠匹配器使用同一目标后重载"],FIXED_ALIAS_REUSED:["固定别名已映射其他原文","固定正则在同一实例仅代表一种原文,改用随机替换或拆分字面规则"],FIXED_SENSITIVE_TARGET:["替换目标包含命中原文","选择不含敏感内容的目标"],FIXED_OVERLAP:["固定替换与其他匹配部分重叠","消除部分重叠或改用随机规则,rule 与 otherRule 指出两条规则"],FIXED_ALIAS_CROSSING:["正则跨越已学习的固定别名","缩小正则范围或更换别名"],SCAN_COMPLEXITY:["请求超过层级或节点上限","减少嵌套数据和工具结构体积"],SCAN_JSON_REWRITE:["替换破坏内嵌 JSON 语法","避免跨 JSON 语法的规则,改为匹配字段值"],SCAN_ATTACHMENT:["不支持图片、音频、视频或文件内容","移除附件或仅发送已审查的文本"],SCAN_KEY_COLLISION:["替换后对象键重复","使用不同目标或避免匹配结构键"],RESTORE_COMPLEXITY:["还原响应时层级过深","减少嵌套结构,或使用 /new 开始新会话"],RESTORE_KEY_COLLISION:["还原后对象键重复","使用更具体的规则或固定替换目标,避免与结构键冲突"],AUDIT_BATCH_LIMIT:["审计批次超过上限","减少单个请求的命中内容"],AUDIT_FULL:["审计日志将超过上限","检查记录后清空日志或本地归档"],AUDIT_PARTIAL:["审计日志末行不完整","本地检查或清空日志,不要上传日志"],AUDIT_LOCKED:["审计锁持续被占用","等待写入完成,全部停止后再检查残留锁"],AUDIT_UNSAFE:["审计文件不安全","使用本人普通文件且不含链接,不会绕过检查"],AUDIT_IO:["审计文件操作失败","检查错误码、权限和磁盘空间,不暴露路径或内容"]},q={blockedHeader:"SPI Protecter blocked this request",reasonLabel:"Reason",actionLabel:"Action",detailsLabel:"Details",statusReady:"ready",statusNotReady:"not ready",statusLine:"SPI Protecter: {state}; {rules}; {mappings}. Memory snapshot; /reload to refresh.",statusLastBlock:"Last block @ {at}:",statusNoBlock:"No recorded block in this session.",statusBarRules:"SPI Protecter · {rules}",statusBarNotReady:"SPI Protecter · not ready",emptyRules:"SPI Protecter: no rules configured; edit the local machine config, then /reload.",commandDescription:"View local SPI protection records",usage:"Usage: /protecter [logs [1-100|clear]|status|reload]. Never type secrets as arguments.",logsTuiOnly:"Open the local TUI to view protection records.",logsNone:"No readable records.",logsSelectTitle:"Protection records",logsTruncated:" (limited tail)",logsRevealTitle:"Sensitive plaintext",logsRevealBody:"Reveal locally? Never share this view.",logsPreviewTitle:"Local preview; edits are discarded",logsPreviewTruncated:"[Preview truncated]",clearTitle:"Clear the current audit log?",clearBody:"Irreversible. Only this machine's log is cleared; configuration and in-memory mappings stay. Later requests may add new records.",cleared:"Audit log cleared: {bytes}.",toolBlocked:"SPI Protecter blocked tool access to the protected configuration or audit log; edit it locally.",compactUnsupported:"SPI Protecter does not support remote context compaction yet; use /new to start a new session."},Ae={blockedHeader:"SPI Protecter 已阻止该请求",reasonLabel:"原因",actionLabel:"处理",detailsLabel:"详情",statusReady:"就绪",statusNotReady:"未就绪",statusLine:"SPI Protecter:{state};{rules};{mappings}。内存快照,重载后更新。",statusLastBlock:"最近一次拦截 @ {at}:",statusNoBlock:"本会话暂无拦截记录。",statusBarRules:"SPI Protecter · {rules}",statusBarNotReady:"SPI Protecter · 未就绪",emptyRules:"SPI Protecter:尚未配置规则,请编辑本地机器配置后重载。",commandDescription:"查看本地保护记录",usage:"用法:/protecter [logs [1-100|clear]|status|reload]。不要在参数中输入敏感内容。",logsTuiOnly:"请在本地 TUI 查看保护记录。",logsNone:"暂无可读记录。",logsSelectTitle:"保护记录",logsTruncated:"(仅显示有限尾部)",logsRevealTitle:"敏感明文",logsRevealBody:"确认在本地显示?请勿分享此界面。",logsPreviewTitle:"本地预览,编辑不会保存",logsPreviewTruncated:"[预览已截断]",clearTitle:"确认清空当前审计日志?",clearBody:"不可撤销。仅清空本机日志,配置和内存映射保留;后续请求可能写入新记录。",cleared:"已清空审计日志:{bytes}。",toolBlocked:"SPI Protecter 已阻止工具访问受保护的配置或审计日志,请在本地编辑。",compactUnsupported:"SPI Protecter 暂不支持远程上下文压缩,请使用 /new 开始新会话。"};function H(n=process.env){let e=n.PI_PROTECTER_LANG||n.LC_ALL||n.LC_MESSAGES||n.LANG||n.LANGUAGE||Se();return/^zh\b|^zh[-_]/i.test(e.trim())?"zh":"en"}function Se(){try{return new Intl.DateTimeFormat().resolvedOptions().locale}catch{return""}}var _=H();function V(){return _}function Q(n){_=n}function m(n,e={}){return((_==="zh"?Ae:q)[n]??q[n]).replace(/\{(\w+)\}/g,(t,o)=>Object.hasOwn(e,o)?String(e[o]):t)}var Te={en:{rules:{one:"{n} rule",other:"{n} rules"},mappings:{one:"{n} mapping",other:"{n} mappings"},bytes:{one:"{n} byte",other:"{n} bytes"}},zh:{rules:{other:"规则 {n} 条"},mappings:{other:"映射 {n} 个"},bytes:{other:"{n} 字节"}}},Z=new Map;function R(n,e){let r=_==="zh"?"zh-CN":"en",t=Z.get(_);if(!t){try{t=new Intl.PluralRules(r)}catch{t=void 0}t&&Z.set(_,t)}let o=t?.select(e)??"other",s=Te[_][n],l=s[o]??s.other,i=new Intl.NumberFormat(r).format(e);return l.replace("{n}",i)}function ee(n){let r=(_==="zh"?_e[n]:z[n])??z[n]??z.INTERNAL;return{reason:r[0],action:r[1]}}var te={INTERNAL:"internal",NOT_READY:"initialization",UNSUPPORTED_PI:"initialization",MACHINE_ID:"initialization",CONFIG_JSON:"configuration",CONFIG_SCHEMA:"configuration",CONFIG_REGEX:"configuration",CONFIG_TARGET:"configuration",CONFIG_CONFLICT:"configuration",CONFIG_SIZE:"configuration",CONFIG_IO:"configuration",CONFIG_UNSAFE:"configuration",CONFIG_EMPTY_MATCH:"configuration",CONFIG_SENSITIVE_TARGET:"configuration",CONFIG_ALIAS_CONFLICT:"configuration",CONFIG_TIMEOUT:"configuration",MIGRATION_LOCKED:"migration",MIGRATION_CHANGED:"migration",MIGRATION_LIMIT:"migration",MIGRATION_IO:"migration",PAYLOAD_JSON:"request",PAYLOAD_SIZE:"request",SCAN_TIMEOUT:"scan",WORKER_FAILED:"worker",SCAN_INTERNAL:"scan",SCAN_NUMBER_UNSAFE:"scan",SCAN_UNIQUE_FAILED:"scan",SCAN_ZERO_WIDTH:"scan",SCAN_MATCH_LIMIT:"scan",SCAN_MAPPING_LIMIT:"scan",FIXED_MAPPING_CONFLICT:"scan",FIXED_ALIAS_REUSED:"scan",FIXED_SENSITIVE_TARGET:"scan",FIXED_OVERLAP:"scan",FIXED_ALIAS_CROSSING:"scan",SCAN_COMPLEXITY:"scan",SCAN_JSON_REWRITE:"scan",SCAN_ATTACHMENT:"scan",SCAN_KEY_COLLISION:"scan",RESTORE_COMPLEXITY:"restore",RESTORE_KEY_COLLISION:"restore",AUDIT_BATCH_LIMIT:"audit",AUDIT_FULL:"audit",AUDIT_PARTIAL:"audit",AUDIT_LOCKED:"audit",AUDIT_UNSAFE:"audit",AUDIT_IO:"audit"},re=["rule","otherRule","node","bytes","limit","timeoutMs"],Ce=/^(EACCES|EPERM|ENOENT|ENOSPC|EDQUOT|EROFS|ELOOP|EISDIR|ENOTDIR|EMFILE|ENFILE|EIO|EEXIST|EBADF|EBUSY|ENOTEMPTY)$/;function we(n,e={}){let{reason:r,action:t}=ee(n),o={};for(let s of re){let l=e[s];typeof l=="number"&&Number.isSafeInteger(l)&&l>=0&&(o[s]=l)}return typeof e.errno=="string"&&Ce.test(e.errno)&&(o.errno=e.errno),{code:n,stage:te[n],reason:r,action:t,...o}}function S(n){let e=V()==="zh"?":":": ",r=[...re,"errno"].filter(t=>n[t]!==void 0).map(t=>`${t}=${n[t]}`).join(" ");return[m("blockedHeader"),`code=${n.code} stage=${n.stage}${r?` ${r}`:""}`,`${m("reasonLabel")}${e}${n.reason}`,`${m("actionLabel")}${e}${n.action}`,`${m("detailsLabel")}${e}/protecter status`].join(`
3
+ `)}var A=class extends Error{diagnostic;constructor(e,r={}){let t=we(e,r);super(S(t)),this.name="ProtectionError",this.diagnostic=t}};function a(n,e={}){return new A(n,e)}function y(n,e,r={}){if(n instanceof A)return a(n.diagnostic.code,{...n.diagnostic,...r});let t=n&&typeof n=="object"&&"code"in n?n.code:void 0;return a(e,{...r,errno:typeof t=="string"?t:void 0})}function b(n,e){if(!n||typeof n!="object")return a(e);let r=n,t=typeof r.code=="string"&&Object.hasOwn(te,r.code)?r.code:e;return a(t,r)}import{constants as T,closeSync as ne,fchmodSync as Re,fstatSync as De,mkdirSync as Le,openSync as oe,readFileSync as Fe,writeFileSync as Pe}from"node:fs";import{dirname as be}from"node:path";var J={version:1,sensitiveWords:[]};function B(n){let e;try{e=JSON.parse(n)}catch{throw a("CONFIG_JSON")}let r=0;try{if(!e||e.version!==1||!Array.isArray(e.sensitiveWords)||Object.keys(e).some(o=>!["version","sensitiveWords"].includes(o)))throw a("CONFIG_SCHEMA");if(e.sensitiveWords.length>1e3)throw a("CONFIG_SIZE",{limit:1e3});let t=new Map;for(let o of e.sensitiveWords){if(r++,typeof o=="string"){if(!o||o.length>8192)throw a("CONFIG_SCHEMA",{rule:r,limit:8192});let l=JSON.stringify(["literal",o]);if(t.has(l)&&t.get(l)!==void 0)throw a("CONFIG_CONFLICT",{rule:r});t.set(l,void 0);continue}if(!o||typeof o!="object")throw a("CONFIG_SCHEMA",{rule:r});if(Object.hasOwn(o,"replacement")&&(typeof o.replacement!="string"||!o.replacement.trim()||o.replacement.length>8192||o.replacement.includes("__PIP_")))throw a("CONFIG_TARGET",{rule:r,limit:8192});if(o.type==="literal"){if(Object.keys(o).some(l=>!["type","value","replacement"].includes(l))||typeof o.value!="string"||!o.value||o.value.length>8192)throw a("CONFIG_SCHEMA",{rule:r,limit:8192})}else if(o.type==="regex"){if(Object.keys(o).some(l=>!["type","pattern","flags","replacement"].includes(l))||typeof o.pattern!="string"||!o.pattern||o.pattern.length>8192||o.flags!==void 0&&(typeof o.flags!="string"||!/^[gimsu]*$/.test(o.flags)))throw a("CONFIG_REGEX",{rule:r,limit:8192});try{new RegExp(o.pattern,o.flags??"")}catch{throw a("CONFIG_REGEX",{rule:r})}}else throw a("CONFIG_SCHEMA",{rule:r});let s=o.type==="literal"?JSON.stringify(["literal",o.value]):JSON.stringify(["regex",o.pattern,[...new Set((o.flags??"")+"g")].sort().join("")]);if(t.has(s)&&t.get(s)!==o.replacement)throw a("CONFIG_CONFLICT",{rule:r});t.set(s,o.replacement)}return e}catch(t){throw t instanceof A?t:a("CONFIG_SCHEMA",{rule:r})}}function ie(n,e=!1){let r;try{if(e){Le(be(n),{recursive:!0,mode:448});try{let s=oe(n,T.O_WRONLY|T.O_CREAT|T.O_EXCL,384);try{Pe(s,JSON.stringify(J,null,2)+`
4
+ `)}finally{ne(s)}}catch(s){if(s.code!=="EEXIST")throw y(s,"CONFIG_IO")}}r=oe(n,T.O_RDONLY|T.O_NOFOLLOW|T.O_NONBLOCK);let t=De(r);if(t.size>1024*1024)throw a("CONFIG_SIZE",{bytes:t.size,limit:1024*1024});if(!t.isFile()||t.nlink!==1||process.getuid&&t.uid!==process.getuid())throw a("CONFIG_UNSAFE");process.platform!=="win32"&&Re(r,384);let o=Fe(r,"utf8");if(Buffer.byteLength(o)>1024*1024)throw a("CONFIG_SIZE",{bytes:Buffer.byteLength(o),limit:1024*1024});return{config:B(o),raw:o}}catch(t){let o=t&&typeof t=="object"&&"code"in t?t.code:void 0;throw y(t,o==="ELOOP"||o==="EISDIR"||o==="ENOTDIR"?"CONFIG_UNSAFE":"CONFIG_IO")}finally{r!==void 0&&ne(r)}}var Y=/^protecter(?:\.[a-f0-9]{32})?\.json$/;function xe(n){return new Promise((e,r)=>{let t=new ve(Ue(new URL("./validate-worker.mjs",import.meta.url)),{workerData:n,execArgv:[],resourceLimits:{maxOldGenerationSizeMb:64}}),o=!1,s=(i,c=a("WORKER_FAILED"))=>{o||(o=!0,clearTimeout(l),t.terminate(),i?e():r(c))},l=setTimeout(()=>s(!1,a("CONFIG_TIMEOUT",{timeoutMs:2e3})),2e3);t.once("message",i=>i===!0?s(!0):s(!1,b(i,"CONFIG_SCHEMA"))),t.once("error",()=>s(!1)),t.once("exit",()=>s(!1))})}function ze(n){let e=new Set,r=[];for(let o of n)for(let s of o.sensitiveWords){let l=typeof s=="string"?JSON.stringify(["literal",s,null]):s.type==="literal"?JSON.stringify(["literal",s.value,s.replacement??null]):JSON.stringify(["regex",s.pattern,[...new Set((s.flags??"")+"g")].sort().join(""),s.replacement??null]);e.has(l)||(e.add(l),r.push(s))}let t=JSON.stringify({version:1,sensitiveWords:r});if(Buffer.byteLength(t)>1024*1024)throw a("CONFIG_SIZE",{bytes:Buffer.byteLength(t),limit:1024*1024});return B(t)}function se(n){let e;try{e=d.lstatSync(n,{bigint:!0})}catch(r){throw y(r,"CONFIG_IO")}if(!e.isFile()||e.nlink!==1n||process.getuid&&e.uid!==BigInt(process.getuid()))throw a("CONFIG_UNSAFE");return`${e.dev}:${e.ino}:${e.size}:${e.mtimeNs}`}function $(n,e){let r=C(n,e),t=se(r),o=ie(r);if(se(r)!==t)throw a("MIGRATION_CHANGED");return{name:e,...o,identity:t,digest:Me("sha256").update(o.raw).digest("hex")}}function D(n,e){let r=$(n,e.name);if(r.identity!==e.identity||r.digest!==e.digest)throw a("MIGRATION_CHANGED")}function G(n){return d.readdirSync(n).filter(e=>Y.test(e)).sort()}function ae(n){if(process.platform==="win32")return;let e=d.openSync(n,d.constants.O_RDONLY);try{d.fsyncSync(e)}finally{d.closeSync(e)}}async function ce(n,e,r={}){if(!/^[a-f0-9]{32}$/.test(e))throw a("MACHINE_ID");let t=Ge(n),o=`protecter.${e}.json`,s=C(t,o);d.mkdirSync(t,{recursive:!0,mode:448});let l=C(t,".protecter-migration.lock"),i=Date.now()+(r.lockTimeoutMs??5e3);for(;;)try{d.mkdirSync(l,{mode:448});break}catch(u){if(u.code!=="EEXIST")throw y(u,"MIGRATION_IO");if(Date.now()>=i)throw a("MIGRATION_LOCKED",{timeoutMs:r.lockTimeoutMs??5e3});await new Promise(O=>setTimeout(O,50))}let c;try{let u=G(t);if(u.length>64)throw a("MIGRATION_LIMIT",{limit:64});let g=[...u].sort((I,w)=>I===o?-1:w===o?1:I.localeCompare(w)).map(I=>$(t,I)),h=ze(g.length?g.map(I=>I.config):[J]);if(await xe([h]),g.length===1&&g[0].name===o){if(D(t,g[0]),JSON.stringify(G(t))!==JSON.stringify(u))throw a("MIGRATION_CHANGED");return{path:s,config:g[0].config,raw:g[0].raw,sourceRaws:[g[0].raw],migrated:0}}let p=JSON.stringify(h,null,2)+`
5
+ `;if(Buffer.byteLength(p)>1024*1024)throw a("CONFIG_SIZE",{bytes:Buffer.byteLength(p),limit:1024*1024});c=C(t,`.protecter-migration.${ke()}.tmp`);let N=d.openSync(c,d.constants.O_WRONLY|d.constants.O_CREAT|d.constants.O_EXCL,384);try{d.writeFileSync(N,p),d.fsyncSync(N)}finally{d.closeSync(N)}if(r.checkpoint?.("beforeCommit",s),JSON.stringify(G(t))!==JSON.stringify(u))throw a("MIGRATION_CHANGED");for(let I of g)D(t,I);d.renameSync(c,s),c=void 0,ae(t);let E=$(t,o);if(E.raw!==p)throw a("MIGRATION_CHANGED");r.checkpoint?.("afterCommit",s);for(let I of g)I.name!==o&&(r.checkpoint?.("beforeDelete",C(t,I.name)),D(t,E),D(t,I),d.unlinkSync(C(t,I.name)));ae(t);let F=[o];if(JSON.stringify(G(t))!==JSON.stringify(F))throw a("MIGRATION_CHANGED");return D(t,E),{path:s,config:h,raw:p,sourceRaws:[...new Set([...g.map(I=>I.raw),p])],migrated:g.filter(I=>I.name!==o).length}}catch(u){throw u instanceof A?u:y(u,"MIGRATION_IO")}finally{if(c)try{d.unlinkSync(c)}catch{}d.rmdirSync(l)}}import{createHash as He}from"node:crypto";import{readFileSync as Je}from"node:fs";import{execFileSync as le}from"node:child_process";function W(n){let e=n.trim().toLowerCase().replace(/-/g,"");if(!/^[a-f0-9]{32}$/.test(e)||/^0+$/.test(e))throw a("MACHINE_ID");return He("sha256").update(`pi-information-protecter:v1:${e}`).digest("hex").slice(0,32)}function ue(){try{if(process.platform==="linux")return W(Je("/etc/machine-id","utf8"));if(process.platform==="darwin"){let n=le("/usr/sbin/ioreg",["-rd1","-c","IOPlatformExpertDevice"],{encoding:"utf8",timeout:5e3,maxBuffer:1048576,stdio:["ignore","pipe","pipe"]});return W(n.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1]??"")}if(process.platform==="win32"){let n=le("reg.exe",["query","HKLM\\SOFTWARE\\Microsoft\\Cryptography","/v","MachineGuid","/reg:64"],{encoding:"utf8",timeout:5e3,maxBuffer:65536,stdio:["ignore","pipe","pipe"]});return W(n.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1]??"")}}catch{}throw a("MACHINE_ID")}import*as f from"node:fs";import{randomUUID as Be}from"node:crypto";var fe=32*1024*1024,de=8*1024*1024;function $e(n){let e=r=>String(r).padStart(2,"0");return`${n.getFullYear()}-${e(n.getMonth()+1)}-${e(n.getDate())} ${e(n.getHours())}:${e(n.getMinutes())}:${e(n.getSeconds())}`}function X(n,e,r=e){let t=f.openSync(n,(e?f.constants.O_RDWR|(r?f.constants.O_CREAT:0):f.constants.O_RDONLY)|f.constants.O_NOFOLLOW|f.constants.O_NONBLOCK,384);try{let o=f.fstatSync(t);if(!o.isFile()||o.nlink!==1||process.getuid&&o.uid!==process.getuid())throw a("AUDIT_UNSAFE");return process.platform!=="win32"&&f.fchmodSync(t,384),t}catch(o){throw f.closeSync(t),y(o,"AUDIT_UNSAFE")}}function M(n){let e=n&&typeof n=="object"&&"code"in n?n.code:void 0;return y(n,e==="ELOOP"||e==="EISDIR"||e==="ENOTDIR"?"AUDIT_UNSAFE":"AUDIT_IO")}async function ge(n){let e=`${n}.lock`,r=Date.now()+2e3;for(;;)try{return f.mkdirSync(e,{mode:448}),e}catch(t){if(t.code!=="EEXIST")throw y(t,"AUDIT_IO");if(Date.now()>=r)throw a("AUDIT_LOCKED",{timeoutMs:2e3});await new Promise(o=>setTimeout(o,25))}}async function me(n){let e=await ge(n),r;try{try{r=X(n,!0,!1)}catch(o){if(o.code==="ENOENT")return 0;throw o}let t=f.fstatSync(r).size;return f.ftruncateSync(r,0),f.fsyncSync(r),t}catch(t){throw M(t)}finally{try{r!==void 0&&f.closeSync(r)}finally{f.rmdirSync(e)}}}async function pe(n,e,r){if(!r.length)return;let t=new Date,o=Be(),s=Buffer.from(r.map(([c,u])=>JSON.stringify({version:1,time:$e(t),timezoneOffset:-t.getTimezoneOffset(),requestId:o,provider:e||"unknown",original:u,replacement:c})).join(`
5
6
  `)+`
6
- `);if(t.length>Ee)throw new Error(O);let s=await X(i),a;try{a=L(i,!0);let f=c.fstatSync(a).size;if(f+t.length>Se)throw new Error;if(f){let d=Buffer.alloc(1);if(c.readSync(a,d,0,1,f-1),d[0]!==10)throw new Error}try{let d=0;for(;d<t.length;){let p=c.writeSync(a,t,d,t.length-d,f+d);if(!p)throw new Error;d+=p}c.fsyncSync(a)}catch{throw c.ftruncateSync(a,f),c.fsyncSync(a),new Error}}catch{throw new Error(O)}finally{a!==void 0&&c.closeSync(a),c.rmdirSync(s)}}function Q(i,e=20){if(!Number.isInteger(e)||e<1||e>100)throw new Error(O);let n;try{try{n=L(i,!1)}catch(g){if(g.code==="ENOENT")return{records:[],truncated:!1};throw g}let r=c.fstatSync(n).size,o=Math.max(0,r-1024*1024),t=Buffer.alloc(r-o),s=c.readSync(n,t,0,t.length,o),a=t.subarray(0,s).toString("utf8");if(o){let g=a.indexOf(`
7
- `);a=g<0?"":a.slice(g+1)}let f=a.split(`
8
- `),d=f.pop()!=="",p=[],l=!1;for(let g of f.slice(-e))try{let m=JSON.parse(g);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;p.push(m)}catch{l=!0}return{records:p,truncated:o>0||f.length>e||d||l}}catch{throw new Error(O)}finally{n!==void 0&&c.closeSync(n)}}var S="SPI Protecter: request cleared; check config, audit log, scan limits or attachments / 请求已清空,请检查配置、审计日志、扫描限制或附件。",x=class{constructor(e,n=2e3,r=H){this.configDir=e;this.timeoutMs=n;this.machineId=r}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 q(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 Q(this.auditPath,e)}clearAuditRecords(){let e=this.queue.then(()=>{if(!this.ready)throw new Error(S);return Y(this.auditPath)});return this.queue=e.catch(()=>{}),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 r=this.queue.then(()=>this.scan(e,n));return this.queue=r.catch(()=>{}),r}async scan(e,n){if(!this.ready)throw new Error(S);let{config:r,sourceRaws:o}=this.snapshot,t=JSON.stringify(e);if(!t||Buffer.byteLength(t)>8*1024*1024)throw new Error(S);let s;try{s=JSON.parse(t)}catch{throw new Error(S)}let a=await new Promise((f,d)=>{let p=!1,l=new ke(ve(new URL("./scan-worker.mjs",import.meta.url)),{workerData:{payload:s,rules:r.sensitiveWords,entries:[...this.tokens],configRaws:o},execArgv:[],resourceLimits:{maxOldGenerationSizeMb:128}});this.workers.add(l);let g=y=>{if(!p){if(p=!0,clearTimeout(m),this.workers.delete(l),l.terminate(),!y||this.closed){d(new Error(S));return}f(y)}},m=setTimeout(()=>g(),this.timeoutMs);l.once("message",y=>g(y.failed?void 0:y)),l.once("error",()=>g()),l.once("exit",()=>g())});if(this.closed)throw new Error(S);if(await K(this.auditPath,n,a.hits),this.closed)throw new Error(S);for(let[f,d]of a.additions)this.tokens.set(f,d);return a.payload}restoreText(e){let n=t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),r=[...this.tokens.keys()].filter(t=>!/^__PIP_[a-f0-9]{48}__$/.test(t)),o=new RegExp(["__PIP_[a-f0-9]{48}__",...r.sort((t,s)=>s.length-t.length).map(n)].join("|"),"g");return e.replace(o,t=>this.tokens.get(t)??t)}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(r=>this.restore(r,n+1));if(e&&typeof e=="object"){let r=Object.create(null);for(let[o,t]of Object.entries(e)){let s=this.restoreText(o);if(Object.hasOwn(r,s))throw new Error("protecter: 响应键冲突。");r[s]=this.restore(t,n+1)}return r}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 be,statSync as V,readdirSync as Ne}from"node:fs";import{homedir as Pe}from"node:os";import{basename as M,dirname as P,isAbsolute as Re,relative as ee,resolve as _}from"node:path";import{fileURLToPath as xe}from"node:url";function _e(i,e){let n=i.replace(/^@/,"");return n.startsWith("file://")&&(n=xe(n)),(n==="~"||n.startsWith("~/"))&&(n=Pe()+n.slice(1)),_(e,n)}function D(i){try{return be(i)}catch{let e=P(i);return e===i?i:_(D(e),ee(e,i))}}function Ce(i,e){let n=ee(i,e);return n===""||!n.startsWith("..")&&!Re(n)}function Z(i,e){try{let n=V(i),r=V(e);return n.dev===r.dev&&n.ino===r.ino}catch{return!1}}function re(i,e,n,r){if(!r)return!0;let o=D(_(r));if(["bash","powershell"].includes(i)){let a=typeof e.command=="string"?e.command:"";if(/protecter(?:\.[a-f0-9]{32})?\.jsonl?|\.protecter-migration/i.test(a)||a.includes(r))return!0}let t=e.path??e.file_path??e.filePath,s=["grep","find","ls"].includes(i);if(typeof t!="string"&&!s)return!1;try{let a=_e(typeof t=="string"?t:".",n),f=D(a),d=/^protecter\.[a-f0-9]{32}\.jsonl(?:\.lock)?$/,p=Ne(P(o)).filter(l=>d.test(l)).some(l=>Z(a,_(P(o),l)));return P(f)===P(o)&&(A.test(M(f))||d.test(M(f))||M(f).startsWith(".protecter-migration"))||f===o||Z(a,r)||p||s&&Ce(f,o)}catch{return!0}}function Te(i){let e=new x(Ie()),n=/^0\.85\./.test(te)&&Number(te.split(".")[2])>=1,r=!1;i.on("session_start",async(o,t)=>{try{if(!n)throw new Error;await e.initialize(),r=!0;let s=e.ruleCount;t.ui.setStatus("protecter",`SPI Protecter · ${s} rules`),s===0&&t.ui.notify("SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。","warning")}catch{r=!1,t.ui.notify(S,"error")}}),i.on("before_provider_request",async(o,t)=>{try{if(!n)throw new Error;let s=await e.redact(o.payload,t.model?.provider??"unknown");return r=!0,s}catch{r=!1;try{await t.abort()}catch{}try{t.ui.notify(S,"error")}catch{}return{}}}),i.on("message_end",o=>{if(o.message.role!=="assistant")return;let t=o.message;return{message:{...t,content:t.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),...t.errorMessage?{errorMessage:e.restoreText(t.errorMessage)}:{}}}}),i.registerMarkdownTransformer((o,t)=>t.messageType==="user"?o:e.restoreText(o)),i.on("tool_call",(o,t)=>{if(!r)return{block:!0,reason:"SPI Protecter 尚未就绪。"};let s=e.restore(o.input);for(let a of Object.keys(o.input))delete o.input[a];if(Object.assign(o.input,s),re(o.toolName,o.input,t.cwd,e.configPath))return{block:!0,reason:"SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。"}}),i.on("session_before_compact",(o,t)=>(t.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:"View local protection records / 查看本地保护记录",handler:async(o,t)=>{let s=o.trim().split(/\s+/).filter(Boolean),a=s[0]??"logs";if(!["status","reload","logs"].includes(a)||a!=="logs"&&s.length>1||s.length>2||s[1]!==void 0&&s[1]!=="clear"&&!/^(?:[1-9]\d?|100)$/.test(s[1])){t.ui.notify("Usage / 用法: /protecter [logs [1-100|clear]|status|reload]. No secrets in arguments / 不要在参数中输入秘密。","info");return}if(a==="logs"){if(t.mode!=="tui"){t.ui.notify("Local TUI required / 请在本地 TUI 查看记录。","warning");return}if(s[1]==="clear"){try{if(!e.ready)throw new Error;if(!await t.ui.confirm("Clear current audit log? / 清空当前审计日志?","Irreversible. Only this machine's log; config and mappings stay. Later requests may add records. / 不可撤销,仅清空本机日志,保留配置和映射,后续请求可能新增记录。"))return;await t.waitForIdle();let f=await e.clearAuditRecords();r=e.ready,t.ui.notify(`Audit cleared: ${f} bytes / 已清空日志:${f} 字节。`,"info")}catch{t.ui.notify("Unable to clear audit log; check permissions or lock / 无法清空日志,请检查权限或锁。","error")}return}try{let{records:f,truncated:d}=e.auditRecords(Number(s[1]??20));if(!f.length){t.ui.notify("No readable records / 暂无可读记录。","info");return}let p=E=>JSON.stringify(E).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,b=>`\\u${b.charCodeAt(0).toString(16).padStart(4,"0")}`),l=f.map((E,b)=>`${b+1}. ${p(E.time)} | ${p(E.provider).slice(0,120)}`),g=await t.ui.select(`Protection records / 保护记录${d?" (limited tail / 有限尾部)":""}`,l),m=g===void 0?-1:l.indexOf(g);if(m<0||!await t.ui.confirm("Sensitive plaintext / 敏感明文","Reveal locally? Never share this view / 确认本地显示?请勿分享此界面。"))return;let y=p(f[m]);await t.ui.editor("Local preview; edits discarded / 本地预览,编辑不保存",y.length>2e4?y.slice(0,2e4)+`
9
- [Preview truncated / 预览截断]`:y)}catch{t.ui.notify("Cannot read audit records / 无法读取审计记录。","error")}return}if(a==="reload"){await t.waitForIdle(),await t.reload();return}t.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{Te as default};
7
+ `);if(s.length>de)throw a("AUDIT_BATCH_LIMIT",{bytes:s.length,limit:de});let l=await ge(n),i;try{i=X(n,!0);let c=f.fstatSync(i).size;if(c+s.length>fe)throw a("AUDIT_FULL",{bytes:c+s.length,limit:fe});if(c){let u=Buffer.alloc(1);if(f.readSync(i,u,0,1,c-1),u[0]!==10)throw a("AUDIT_PARTIAL")}try{let u=0;for(;u<s.length;){let O=f.writeSync(i,s,u,s.length-u,c+u);if(!O)throw a("AUDIT_IO");u+=O}f.fsyncSync(i)}catch(u){throw f.ftruncateSync(i,c),f.fsyncSync(i),M(u)}}catch(c){throw M(c)}finally{i!==void 0&&f.closeSync(i),f.rmdirSync(l)}}function he(n,e=20){if(!Number.isInteger(e)||e<1||e>100)throw a("INTERNAL",{limit:100});let r;try{try{r=X(n,!1)}catch(h){if(h.code==="ENOENT")return{records:[],truncated:!1};throw h}let t=f.fstatSync(r).size,o=Math.max(0,t-1024*1024),s=Buffer.alloc(t-o),l=f.readSync(r,s,0,s.length,o),i=s.subarray(0,l).toString("utf8");if(o){let h=i.indexOf(`
8
+ `);i=h<0?"":i.slice(h+1)}let c=i.split(`
9
+ `),u=c.pop()!=="",O=[],g=!1;for(let h of c.slice(-e))try{let p=JSON.parse(h);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;O.push(p)}catch{g=!0}return{records:O,truncated:o>0||c.length>e||u||g}}catch(t){throw M(t)}finally{r!==void 0&&f.closeSync(r)}}var k=class{constructor(e,r=2e3,t=ue){this.configDir=e;this.timeoutMs=r;this.machineId=t}configDir;timeoutMs;machineId;tokens=new Map;queue=Promise.resolve();workers=new Set;closed=!1;snapshot;lastError;restorePattern;restoreSize=-1;get lastDiagnostic(){return this.lastError?{...this.lastError}:void 0}get lastDiagnosticText(){return this.lastError?S(this.lastError):void 0}async initialize(){try{if(this.closed)throw a("NOT_READY");this.snapshot=void 0;let e;try{e=this.machineId()}catch{throw a("MACHINE_ID")}let r=await ce(this.configDir,e);if(this.closed)throw a("NOT_READY");this.snapshot=r,this.lastError=void 0}catch(e){let r=y(e,"CONFIG_IO");throw this.lastError=r.diagnostic,r}}get configPath(){return this.snapshot?.path??""}get auditPath(){return this.configPath?this.configPath.replace(/\.json$/,".jsonl"):""}auditRecords(e=20){if(!this.ready)throw this.notReady();return he(this.auditPath,e)}clearAuditRecords(){let e=this.queue.then(()=>{if(!this.ready)throw this.notReady();return me(this.auditPath)});return this.queue=e.catch(()=>{}),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)).catch(o=>{let s=y(o,"INTERNAL");throw this.lastError=s.diagnostic,s});return this.queue=t.catch(()=>{}),t}notReady(){return this.lastError?a(this.lastError.code,this.lastError):a("NOT_READY")}async scan(e,r){if(!this.ready)throw this.notReady();let{config:t,sourceRaws:o}=this.snapshot,s;try{s=JSON.stringify(e)}catch{throw a("PAYLOAD_JSON")}if(!s)throw a("PAYLOAD_JSON");if(Buffer.byteLength(s)>8*1024*1024)throw a("PAYLOAD_SIZE",{bytes:Buffer.byteLength(s),limit:8*1024*1024});let l;try{l=JSON.parse(s)}catch{throw a("PAYLOAD_JSON")}let i=await new Promise((c,u)=>{let O=!1,g;try{g=new Ye(We(new URL("./scan-worker.mjs",import.meta.url)),{workerData:{payload:l,rules:t.sensitiveWords,entries:[...this.tokens],configRaws:o},execArgv:[],resourceLimits:{maxOldGenerationSizeMb:128}})}catch(N){u(y(N,"WORKER_FAILED"));return}this.workers.add(g);let h=(N,E=a("WORKER_FAILED"))=>{if(!O){if(O=!0,clearTimeout(p),this.workers.delete(g),g.terminate(),!N||this.closed){u(this.closed?a("NOT_READY"):E);return}c(N)}},p=setTimeout(()=>h(void 0,a("SCAN_TIMEOUT",{timeoutMs:this.timeoutMs})),this.timeoutMs);g.once("message",N=>{N?.failed?h(void 0,b(N.diagnostic,"SCAN_INTERNAL")):N&&Array.isArray(N.additions)&&Array.isArray(N.hits)?h(N):h()}),g.once("error",()=>h()),g.once("exit",()=>h())});if(this.closed)throw a("NOT_READY");if(await pe(this.auditPath,r,i.hits),this.closed)throw a("NOT_READY");for(let[c,u]of i.additions)this.tokens.set(c,u);return i.payload}restoreText(e){if(!this.tokens.size)return e;if(this.restoreSize!==this.tokens.size){let r=t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");this.restorePattern=new RegExp([...this.tokens.keys()].sort((t,o)=>o.length-t.length).map(r).join("|"),"g"),this.restoreSize=this.tokens.size}return e.replace(this.restorePattern,r=>this.tokens.get(r)??r)}restore(e,r=0){if(r>80)throw a("RESTORE_COMPLEXITY",{node:r});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,s]of Object.entries(e)){let l=this.restoreText(o);if(Object.hasOwn(t,l))throw a("RESTORE_KEY_COLLISION");t[l]=this.restore(s,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 Xe,statSync as Ie,readdirSync as je}from"node:fs";import{homedir as Ke}from"node:os";import{basename as j,dirname as L,isAbsolute as qe,relative as Ne,resolve as v}from"node:path";import{fileURLToPath as Ze}from"node:url";function Ve(n,e){let r=n.replace(/^@/,"");return r.startsWith("file://")&&(r=Ze(r)),(r==="~"||r.startsWith("~/"))&&(r=Ke()+r.slice(1)),v(e,r)}function K(n){try{return Xe(n)}catch{let e=L(n);return e===n?n:v(K(e),Ne(e,n))}}function Qe(n,e){let r=Ne(n,e);return r===""||!r.startsWith("..")&&!qe(r)}function ye(n,e){try{let r=Ie(n),t=Ie(e);return r.dev===t.dev&&r.ino===t.ino}catch{return!1}}function Oe(n,e,r,t){if(!t)return!0;let o=K(v(t));if(["bash","powershell"].includes(n)){let i=typeof e.command=="string"?e.command:"";if(/protecter(?:\.[a-f0-9]{32})?\.jsonl?|\.protecter-migration/i.test(i)||i.includes(t))return!0}let s=e.path??e.file_path??e.filePath,l=["grep","find","ls"].includes(n);if(typeof s!="string"&&!l)return!1;try{let i=Ve(typeof s=="string"?s:".",r),c=K(i),u=/^protecter\.[a-f0-9]{32}\.jsonl(?:\.lock)?$/,O=je(L(o)).filter(g=>u.test(g)).some(g=>ye(i,v(L(o),g)));return L(c)===L(o)&&(Y.test(j(c))||u.test(j(c))||j(c).startsWith(".protecter-migration"))||c===o||ye(i,t)||O||l&&Qe(c,o)}catch{return!0}}function tt(n){Q(H());let e=new k(et()),r=/^0\.85\./.test(Ee)&&Number(Ee.split(".")[2])>=1,t=!1,o,s=l=>{let i=S(l);return o={at:new Date().toISOString(),text:i},i};n.on("session_start",async(l,i)=>{try{if(!r)throw a("UNSUPPORTED_PI");await e.initialize(),t=!0;let c=e.ruleCount;i.ui.setStatus("protecter",m("statusBarRules",{rules:R("rules",c)})),c===0&&i.ui.notify(m("emptyRules"),"warning")}catch(c){t=!1,i.ui.setStatus("protecter",m("statusBarNotReady")),i.ui.notify(s(y(c,"CONFIG_IO").diagnostic),"error")}}),n.on("before_provider_request",async(l,i)=>{try{if(!r)throw a("UNSUPPORTED_PI");let c=await e.redact(l.payload,i.model?.provider??"unknown");return t=!0,c}catch(c){t=!1;let u=s(y(c,"INTERNAL").diagnostic);try{await i.abort()}catch{}try{i.ui.notify(u,"error")}catch{}return{}}}),n.on("message_end",l=>{if(l.message.role!=="assistant")return;let i=l.message;return{message:{...i,content:i.content.map(c=>c.type==="text"?{...c,text:e.restoreText(c.text)}:c.type==="thinking"?{...c,thinking:e.restoreText(c.thinking)}:c.type==="toolCall"?{...c,arguments:e.restore(c.arguments)}:c),...i.errorMessage?{errorMessage:e.restoreText(i.errorMessage)}:{}}}}),n.registerMarkdownTransformer((l,i)=>i.messageType==="user"?l:e.restoreText(l)),n.on("tool_call",(l,i)=>{if(!t)return{block:!0,reason:e.lastDiagnosticText??o?.text??S(a("NOT_READY").diagnostic)};let c=e.restore(l.input);for(let u of Object.keys(l.input))delete l.input[u];if(Object.assign(l.input,c),Oe(l.toolName,l.input,i.cwd,e.configPath))return{block:!0,reason:m("toolBlocked")}}),n.on("session_before_compact",(l,i)=>(i.ui.notify(m("compactUnsupported"),"warning"),{cancel:!0})),n.on("session_before_tree",l=>l.preparation.userWantsSummary?{cancel:!0}:void 0),n.registerCommand("protecter",{description:m("commandDescription"),handler:async(l,i)=>{let c=l.trim().split(/\s+/).filter(Boolean),u=c[0]??"logs";if(!["status","reload","logs"].includes(u)||u!=="logs"&&c.length>1||c.length>2||c[1]!==void 0&&c[1]!=="clear"&&!/^(?:[1-9]\d?|100)$/.test(c[1])){i.ui.notify(m("usage"),"info");return}if(u==="logs"){if(i.mode!=="tui"){i.ui.notify(m("logsTuiOnly"),"warning");return}if(c[1]==="clear"){try{if(!e.ready)throw a("NOT_READY");if(!await i.ui.confirm(m("clearTitle"),m("clearBody")))return;await i.waitForIdle();let p=await e.clearAuditRecords();t=e.ready,i.ui.notify(m("cleared",{bytes:R("bytes",p)}),"info")}catch(p){i.ui.notify(s(y(p,"AUDIT_IO").diagnostic),"error")}return}try{let{records:p,truncated:N}=e.auditRecords(Number(c[1]??20));if(!p.length){i.ui.notify(m("logsNone"),"info");return}let E=P=>JSON.stringify(P).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,x=>`\\u${x.charCodeAt(0).toString(16).padStart(4,"0")}`),F=p.map((P,x)=>`${x+1}. ${E(P.time)} | ${E(P.provider).slice(0,120)}`),I=await i.ui.select(m("logsSelectTitle")+(N?m("logsTruncated"):""),F),w=I===void 0?-1:F.indexOf(I);if(w<0||!await i.ui.confirm(m("logsRevealTitle"),m("logsRevealBody")))return;let U=E(p[w]);await i.ui.editor(m("logsPreviewTitle"),U.length>2e4?`${U.slice(0,2e4)}
10
+ ${m("logsPreviewTruncated")}`:U)}catch(p){i.ui.notify(s(y(p,"AUDIT_IO").diagnostic),"error")}return}if(u==="reload"){await i.waitForIdle(),await i.reload();return}let O=e.lastDiagnosticText??o?.text,g=m("statusLine",{state:m(e.ready?"statusReady":"statusNotReady"),rules:R("rules",e.ruleCount),mappings:R("mappings",e.mappingCount)}),h=O?`
11
+ ${m("statusLastBlock",{at:o?.at??""})}
12
+ ${O}`:`
13
+ ${m("statusNoBlock")}`;i.ui.notify(g+h,e.ready?"info":"error")}}),n.on("session_shutdown",()=>e.close())}export{tt as default};
@@ -1,2 +1,2 @@
1
1
  /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
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;
2
+ import{parentPort as J,workerData as k}from"node:worker_threads";import{randomBytes as H}from"node:crypto";var $=[[48,57],[65,90],[97,122],[192,214],[216,246],[248,255],[256,383],[7680,7935],[913,937],[945,969],[940,974],[1040,1103],[1488,1514],[1569,1594],[1601,1610],[3585,3630],[3632,3635],[3636,3642],[3648,3652],[3655,3662],[12353,12438],[12449,12538],[44032,55203],[19968,40869]],P=BigInt(Number.MAX_SAFE_INTEGER);function C(s){if(s<=1n)return 0n;let u=s.toString(2).length,l=Math.ceil(u/8),h=(1n<<BigInt(u))-1n;for(let d=0;d<64;d++){let f=0n;for(let g of H(l))f=f<<8n|BigInt(g);if(f&=h,f<s)return f}return s-1n}function U(s){return $.find(([u,l])=>s>=u&&s<=l)}function j(s){let u="";for(let l of s){let h=l.codePointAt(0),d=U(h);if(!d){u+=l;continue}let[f,g]=d,y=BigInt(g-f+1);u+=String.fromCodePoint(f+Number(C(y)))}return u}function F(s){if(!/^\d+$/.test(s))return;if(s.length===1)return s==="0"?void 0:String(1n+C(9n));if(s.startsWith("0")){let d="";for(let f of s)d+=f==="0"?"0":String(1n+C(9n));return d}let u=10n**BigInt(s.length-1),l=10n**BigInt(s.length)-1n,h=l>P?P:l;if(!(h<u))return String(u+C(h-u+1n))}function B(s){let u=Number(s);return Number.isFinite(u)&&String(u)===s}var N,M=0;function c(s,u){throw{code:s,rule:N,otherRule:u,node:M}}try{let L=function(e,r){for(let n=0;n<64;n++){let t=r&&/^\d+$/.test(e)?F(e):j(e);if(!(!t||t===e)&&!f.has(t)&&!d.has(t)&&!X.includes(t))return t}},D=function(e,r,n=!1){let t=d.get(e);return t?(r!==void 0&&t!==r&&c("FIXED_MAPPING_CONFLICT"),t):r!==void 0?(f.size>=5e4&&c("SCAN_MAPPING_LIMIT"),f.has(r)&&f.get(r)!==e&&c("FIXED_ALIAS_REUSED"),r.includes(e)&&c("FIXED_SENSITIVE_TARGET"),d.set(e,r),f.set(r,e),g.push([r,e]),r):(f.size>=5e4&&c("SCAN_MAPPING_LIMIT"),t=L(e,n),t||c("SCAN_UNIQUE_FAILED"),d.set(e,t),f.set(t,e),g.push([t,e]),t)},E=function(e,r=!1){let n=[];for(let o of T)if(N=o.rule,o.literal===void 0){o.regex.lastIndex=0;for(let i of e.matchAll(o.regex))i[0].length||c("SCAN_ZERO_WIDTH"),n.push([i.index,i.index+i[0].length,o.replacement,o.rule]),++O>1e5&&c("SCAN_MATCH_LIMIT")}else{if(!o.literal)continue;let i=0;for(;(i=e.indexOf(o.literal,i))!==-1;)n.push([i,i+o.literal.length,o.replacement,o.rule]),++O>1e5&&c("SCAN_MATCH_LIMIT"),i++}n.sort((o,i)=>o[0]-i[0]||i[1]-o[1]);let t=[];for(let o of n){let i=t.at(-1);i&&o[0]<i[1]?((i[2]!==void 0||o[2]!==void 0)&&((i[0]!==o[0]||i[1]!==o[1]||i[2]!==void 0&&o[2]!==void 0&&i[2]!==o[2])&&(N=i[3],c("FIXED_OVERLAP",o[3])),i[2]===void 0&&o[2]!==void 0&&(i[3]=o[3]),i[2]??=o[2]),i[1]=Math.max(i[1],o[1])):t.push([...o])}let x="",p=0;for(let[o,i,a,A]of t){N=A;let S=D(e.slice(o,i),a,r);y.add(S),x+=e.slice(p,o)+S,p=i}return x+e.slice(p)},_=function(e,r=!1){let n="",t=0,x=a=>a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),p=[...f.keys()];if(!p.length)return E(e,r);let o=new RegExp(p.sort((a,A)=>A.length-a.length).map(x).join("|"),"g"),i=[...e.matchAll(o)].filter(a=>f.has(a[0]));for(let a of T)if(!(!a.regex||!p.length)){N=a.rule,a.regex.lastIndex=0;for(let A of e.matchAll(a.regex))++O>1e5&&c("SCAN_MATCH_LIMIT"),A[0].length||c("SCAN_ZERO_WIDTH"),i.some(S=>p.includes(S[0])&&A.index<S.index+S[0].length&&A.index+A[0].length>S.index)&&c("FIXED_ALIAS_CROSSING")}for(let a of i)f.has(a[0])&&(n+=E(e.slice(t,a.index),r)+a[0],t=a.index+a[0].length);return n+E(e.slice(t),r)},I=function(e,r=0){if(M=++b,N=void 0,(b>2e5||r>80)&&c("SCAN_COMPLEXITY"),typeof e=="string"){if(R.some(n=>e.includes(n)))return _(e);if(/^\s*[[{]/.test(e)){let n;try{n=JSON.parse(e)}catch{}if(n&&typeof n=="object"){let t=JSON.stringify(I(n,r+1)),x=_(t);if(x===t)return t;try{JSON.parse(x)}catch{c("SCAN_JSON_REWRITE")}return x}}return _(e)}if(typeof e=="number"){let n=String(e),t=_(n,!0);return t===n?e:(B(t)||c("SCAN_NUMBER_UNSAFE"),Number(t))}if(Array.isArray(e))return e.map(n=>I(n,r+1));if(e&&typeof e=="object"){typeof e.type=="string"&&/image|audio|video|document|(^|_)file($|_)/i.test(e.type)&&c("SCAN_ATTACHMENT"),Object.keys(e).some(t=>/^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(t))&&c("SCAN_ATTACHMENT");let n=Object.create(null);for(let[t,x]of Object.entries(e)){let p=_(t);Object.hasOwn(n,p)&&c("SCAN_KEY_COLLISION"),n[p]=I(x,r+1)}return n}if(e===null||typeof e=="boolean")return e;c("PAYLOAD_JSON")},m=function(e,r,n=0){if(n>80&&c("SCAN_COMPLEXITY"),typeof e=="string"){if(e.includes(r))return!0;let t;try{t=JSON.parse(e)}catch{return!1}return t&&typeof t=="object"?m(t,r,n+1):!1}return Array.isArray(e)?e.some(t=>m(t,r,n+1)):e&&typeof e=="object"?Object.entries(e).some(([t,x])=>t.includes(r)||m(x,r,n+1)):!1};W=L,Y=D,z=E,K=_,V=I,Z=m;let{payload:s,rules:u,entries:l,configRaws:h}=k,d=new Map(l.map(([e,r])=>[r,e])),f=new Map(l),g=[],y=new Set,T=u.map((e,r)=>{let n=r+1;return typeof e=="string"?{literal:e,rule:n}:e.type==="literal"?{literal:e.value,replacement:e.replacement,rule:n}:{rule:n,replacement:e.replacement,regex:new RegExp(e.pattern,[...new Set((e.flags??"")+"g")].join(""))}});for(let e of d.keys())T.push({literal:e});let R=[...new Set(h.flatMap(e=>[e,JSON.stringify(JSON.parse(e))]))];for(let e of R)T.push({literal:e});let X=JSON.stringify(s),O=0,b=0,w=I(s),G=[...y].filter(e=>m(w,e)).map(e=>[e,f.get(e)]);J.postMessage({payload:w,additions:g,hits:G})}catch(s){J.postMessage({failed:!0,diagnostic:{code:typeof s?.code=="string"?s.code:"SCAN_INTERNAL",rule:s?.rule??N,otherRule:s?.otherRule,node:s?.node??M}})}var W,Y,z,K,V,Z;
@@ -1,2 +1,2 @@
1
1
  /*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
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)}
2
+ import{parentPort as a,workerData as d}from"node:worker_threads";var i;function u(n,c){throw{code:n,rule:i,otherRule:c}}try{for(let n of d){let s=n.sensitiveWords.map((e,r)=>({rule:e,index:r+1})),l=s.filter(e=>typeof e.rule=="object"&&e.rule.replacement!==void 0);for(let e of s)i=e.index,typeof e.rule=="object"&&e.rule.type==="regex"&&new RegExp(e.rule.pattern,e.rule.flags??"").test("")&&u("CONFIG_EMPTY_MATCH");for(let e=0;e<l.length;e++){let r=l[e].rule.replacement;i=l[e].index;for(let o of s){let t=o.rule;(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))&&u("CONFIG_SENSITIVE_TARGET",o.index)}for(let o=0;o<e;o++){let t=l[o],f=t.rule.replacement;!r.includes(f)&&!f.includes(r)||JSON.stringify(l[e].rule)!==JSON.stringify(t.rule)&&u("CONFIG_ALIAS_CONFLICT",t.index)}}}a.postMessage(!0)}catch(n){a.postMessage({code:typeof n?.code=="string"?n.code:"CONFIG_SCHEMA",rule:n?.rule??i,otherRule:n?.otherRule})}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-information-protecter",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Sensitive Personal Information (SPI) Protecter — local reversible privacy redaction for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",