pi-information-protecter 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +26 -6
- package/SECURITY.md +6 -2
- package/dist/index.mjs +8 -3
- package/dist/scan-worker.mjs +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog / 更新日志
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — Private redaction audit / 私有脱敏审计
|
|
4
|
+
|
|
5
|
+
- Persist successful distinct original/token pairs per request in `protecter.<machineHash>.jsonl`, with local timestamp, UTC offset, provider ID and request ID. This intentionally adds plaintext sensitive data on disk; records represent local redaction, not confirmed network delivery.
|
|
6
|
+
按请求持久化唯一原文与占位符到私有 JSONL,含本地时间、时区偏移、供应商及请求 ID;明确新增磁盘敏感明文,记录表示本地脱敏而非网络已送达。
|
|
7
|
+
- `/protecter` and `logs [1-100]` open a bounded local TUI viewer with confirmation; keep status/reload subcommands, never inject audit records into model/session context.
|
|
8
|
+
命令提供有限本地查看及确认,保留状态和重载,不将审计注入模型或会话。
|
|
9
|
+
- Add private permissions, unsafe-link rejection, cooperative write locks, fsync, log limits and fail-closed audit errors; protect log paths and aliases from direct tools.
|
|
10
|
+
增加私有权限、链接拒绝、协作写锁、落盘同步、日志限制及失败拒绝请求,保护日志路径和别名。
|
|
11
|
+
- Add source and packed-distribution regression coverage for audit writing and command UI.
|
|
12
|
+
增加源码及实际发布包的日志写入和命令界面回归验证。
|
|
13
|
+
|
|
3
14
|
## 0.2.1 — Compiled distribution / 编译产物发布
|
|
4
15
|
|
|
5
16
|
- 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.
|
|
17
|
+
pi install npm:pi-information-protecter@0.3.0
|
|
18
18
|
|
|
19
19
|
# Alternatively, build and install from this repository without copying it.
|
|
20
20
|
npm ci
|
|
@@ -60,9 +60,19 @@ 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`
|
|
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
|
+
## Private audit log
|
|
67
|
+
|
|
68
|
+
Successful request-body redactions append to `protecter.<machineHash>.jsonl` beside the configuration. Each JSON line includes `time` (`yyyy-mm-dd HH:mm:ss`, local time), `timezoneOffset` (minutes east of UTC), `provider`, `original`, `replacement`, `requestId` and `version`. Provider is Pi's selected provider ID, not an inferred upstream gateway vendor; unavailable metadata is recorded as `unknown`.
|
|
69
|
+
|
|
70
|
+
One record is written per distinct original/token pair per successful scan; repeated occurrences in that request are deduplicated. Replacing the same original on a later request creates a new record, even when its token is reused. Existing tokens and requests without matches add no records. These are **local redaction events, not proof of network delivery**: cancellation, another hook or a provider failure can occur afterward.
|
|
71
|
+
|
|
72
|
+
`/protecter` or `/protecter logs [1-100]` reads the most recent records (20 by default). Local TUI only: choose a time/provider entry, then confirm before revealing plaintext. The editor is a preview and discards edits. Controls are escaped; long previews are truncated. RPC/print modes do not display secrets, and the command never calls `sendMessage` or writes session entries.
|
|
73
|
+
|
|
74
|
+
**Logs contain real secrets and reversible mappings.** Unix permissions are 0600; symlinks, hard links and other-user files are rejected. Tool guards cover current/old hash log names and aliases but are not a sandbox. Logs are not encrypted, automatically rotated, migrated with configuration or used to rebuild mappings. Stop all Pi processes before locally archiving/deleting logs or inspecting a stale `.jsonl.lock`. Reading is bounded to the last 1 MiB, display to 100 records and 20,000 characters per preview. A log is capped at 32 MiB and a request batch at 8 MiB; full, unwritable, partial-tail or locked logs block matched requests rather than silently losing audits. Failed scans add no records; a hard crash can leave a partial batch that requires local repair.
|
|
75
|
+
|
|
66
76
|
## How it works
|
|
67
77
|
|
|
68
78
|
```text
|
|
@@ -76,7 +86,7 @@ Local response and tool-argument restoration
|
|
|
76
86
|
```
|
|
77
87
|
|
|
78
88
|
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)`.
|
|
89
|
+
2. Generate tokens with `crypto.randomBytes(24)`. Runtime mappings stay in memory and are not restored across restarts. **Since 0.3.0, successful original/token pairs are also persisted in the private audit log.** They are never injected into configuration, session custom entries or model prompts.
|
|
80
90
|
3. Restore finalized assistant text, thinking and tool arguments. The TUI Markdown transformer restores complete streamed tokens; partial tokens may briefly appear. RPC/JSON deltas remain masked until final `message_end`. Altered or truncated tokens cannot be restored.
|
|
81
91
|
4. Restore tool arguments before execution so legitimate local operations use original values; redact them again on subsequent requests. **Restoring credentials is not tool authorization or exfiltration prevention.**
|
|
82
92
|
5. Execute regex scans in a terminable worker. On failure, return `{}` and request cancellation rather than relying on hook exceptions swallowed by Pi. An empty request or provider validation error may still occur, but the handler does not return the original payload.
|
|
@@ -130,7 +140,7 @@ API references: [Pi Extensions](https://pi.dev/docs/latest/extensions), local 0.
|
|
|
130
140
|
|
|
131
141
|
```bash
|
|
132
142
|
# 安装固定 npm 版本。
|
|
133
|
-
pi install npm:pi-information-protecter@0.
|
|
143
|
+
pi install npm:pi-information-protecter@0.3.0
|
|
134
144
|
|
|
135
145
|
# 或在本仓库构建后本地安装,不复制仓库。
|
|
136
146
|
npm ci
|
|
@@ -176,9 +186,19 @@ pi -e ./src/index.ts
|
|
|
176
186
|
- JSON 中的反斜杠写作 `\\`。不允许匹配空字符串;运行时零宽匹配会拒绝请求。
|
|
177
187
|
- 合并重叠匹配,避免暴露长敏感值的后缀;同一实例内相同原文复用占位符。
|
|
178
188
|
- 已识别原文在当前实例内持续受到精确匹配保护,即使正则上下文消失或规则被删除;退出或重载清除记录。
|
|
179
|
-
-
|
|
189
|
+
- `/protecter` 打开本地审计查看器,`/protecter logs 50` 查看最近最多 50 条;`/protecter status` 只报告内存数量,不读取配置。`/protecter reload` 等待空闲后完整重载。不要在命令参数中填写秘密。
|
|
180
190
|
- 限制为 1000 条规则、每个词或模式 8192 字符、扫描 2 秒、请求 JSON 8 MiB;超限拒绝,不降级发送明文。
|
|
181
191
|
|
|
192
|
+
## 私有审计日志
|
|
193
|
+
|
|
194
|
+
成功脱敏的请求会追加记录到配置旁的 `protecter.<machineHash>.jsonl`。每行包含 `time`(本地时间 `yyyy-mm-dd HH:mm:ss`)、`timezoneOffset`(相对 UTC 向东的分钟数)、`provider`、`original`、`replacement`、`requestId` 和 `version`。供应商为 Pi 当前选择的 provider ID,不猜测网关背后的上游厂商;无法获取时记录 `unknown`。
|
|
195
|
+
|
|
196
|
+
每次成功扫描,对不同的原文与占位符组合分别记录一行,同一请求内重复出现会去重。后续请求再次替换同一原文时仍新增记录,即使复用占位符。已有占位符和无命中请求不新增记录。日志表示**本地脱敏事件,而非网络已成功发送**,之后仍可能取消、被其他钩子修改或发生提供商错误。
|
|
197
|
+
|
|
198
|
+
`/protecter` 或 `/protecter logs [1-100]` 查看最近记录,默认 20 条。仅在本地 TUI 可用:先选择时间及供应商条目,确认后才显示明文。编辑器只作预览,修改不保存;控制字符转义,过长预览截断。RPC、打印模式不展示秘密,命令不调用消息发送接口,也不写入会话条目。
|
|
199
|
+
|
|
200
|
+
**日志包含真实秘密及可逆映射。** Unix 权限为 0600,拒绝符号链接、硬链接及他人文件。工具防护覆盖当前和旧机器哈希日志及别名,但不是沙箱。日志不加密、不自动轮转、不随配置迁移,也不用于恢复映射。请停止全部 Pi 进程后再本地归档、删除日志或检查残留 `.jsonl.lock`。读取限最后 1 MiB、最多 100 条,每条预览最多 20,000 字符;单日志上限 32 MiB、请求批次 8 MiB。日志满、不可写、尾部残缺或锁占用时,命中请求会被阻止,不静默漏记。扫描失败不新增记录,硬崩溃可能遗留部分批次,需本地修复。
|
|
201
|
+
|
|
182
202
|
## 工作方式
|
|
183
203
|
|
|
184
204
|
```text
|
|
@@ -192,7 +212,7 @@ __PIP_<192-bit 随机十六进制>__ → LLM 网关
|
|
|
192
212
|
```
|
|
193
213
|
|
|
194
214
|
1. 扫描 JSON 字符串、键和数字,包括解码后的 JSON 字符串工具参数;不修改本地用户输入和历史原文。
|
|
195
|
-
2. 使用 `crypto.randomBytes(24)`
|
|
215
|
+
2. 使用 `crypto.randomBytes(24)` 生成占位符。运行时映射保存在内存,不跨重启恢复。**从 0.3.0 起,成功替换的原文和占位符也会持久化到私有审计日志。** 不注入配置、会话自定义条目或模型提示。
|
|
196
216
|
3. 还原最终 assistant 文本、思考内容和工具参数。TUI 在完整占位符到达后还原,片段可能短暂显示;RPC/JSON 增量仍脱敏,直到最终消息还原。被改写或截断的占位符无法还原。
|
|
197
217
|
4. 工具执行前还原参数,让合法本地操作使用原值;后续请求再次脱敏。**凭证还原不等于工具授权或防外传。**
|
|
198
218
|
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
|
|
23
|
-
|
|
22
|
+
- Scan outgoing payload text with random 192-bit placeholders. Runtime mappings stay in memory, but version 0.3.0 adds plaintext original/token audit pairs in private JSONL. Audit files are sensitive credential stores, not encrypted archives; never upload them. Logs persist after exit and are not loaded to restore tokens.
|
|
23
|
+
出站文本使用随机占位符,运行映射保存在内存,但 0.3.0 新增私有 JSONL 原文与占位符审计。日志属于敏感凭证存储,不是加密归档,禁止上传;退出后仍保留,不用于恢复占位符。
|
|
24
|
+
- Audit writes use 0600 files, safe-open checks, a cooperative lock and fsync before returning a redacted payload. Logging errors fail closed. Success means local replacement, not provider delivery. File size and preview limits bound resource usage; users manage retention locally. Hard crashes may leave stale locks or partial records. Same-UID writers and terminal capture remain outside the threat model.
|
|
25
|
+
日志使用 0600、安全打开检查、协作锁,并在返回脱敏请求前同步落盘;日志失败则拒绝请求。成功表示本地替换而非网关已接收。文件及预览大小受限,用户本地管理保留策略;硬崩溃可能残留锁或不完整记录,同用户权限攻击和终端录屏仍不在防护范围。
|
|
26
|
+
- `/protecter` previews are TUI-only and require confirmation before showing original values; nothing is injected into model context or session entries. Trusted extensions may still observe UI hooks. Configuration caching remains memory-only during requests, but matched requests now perform audit-file writes.
|
|
27
|
+
查看器仅限 TUI,展示原文前需确认,不注入模型上下文或会话条目;可信扩展仍可能观察 UI 钩子。出站规则依然使用内存缓存,但命中请求现在会写审计文件。
|
|
24
28
|
- Limit worker regex scans to 2 seconds, with resource and input limits. Errors exclude original configuration, rules and request text.
|
|
25
29
|
worker 正则扫描限制为 2 秒,并限制资源和输入;错误不包含原始配置、规则或请求文本。
|
|
26
30
|
- Pi 0.85.1 catches `before_provider_request` errors and continues with the current payload. Error paths therefore return `{}` and attempt cancellation. This does not guarantee zero HTTP requests; it prevents this handler from falling back to the original payload.
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
/*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
|
|
2
|
-
import{getAgentDir as
|
|
3
|
-
`)}finally{
|
|
4
|
-
`;if(Buffer.byteLength(
|
|
2
|
+
import{getAgentDir as Ce,VERSION as ee}from"@earendil-works/pi-coding-agent";import{Worker as Ee}from"node:worker_threads";import{fileURLToPath as Oe}from"node:url";import*as l from"node:fs";import{join as v,resolve as ae}from"node:path";import{createHash as ce,randomUUID as fe}from"node:crypto";import{Worker as ue}from"node:worker_threads";import{fileURLToPath as le}from"node:url";import{constants as O,closeSync as F,fchmodSync as re,fstatSync as te,mkdirSync as ne,openSync as W,readFileSync as oe,writeFileSync as ie}from"node:fs";import{dirname as se}from"node:path";var _={version:1,sensitiveWords:[]},w="SPI Protecter: unsafe or invalid configuration / 配置不安全或格式无效,请在本地修复。";function T(o){try{let e=JSON.parse(o);if(!e||e.version!==1||!Array.isArray(e.sensitiveWords)||e.sensitiveWords.length>1e3||Object.keys(e).some(r=>!["version","sensitiveWords"].includes(r)))throw new Error;for(let r of e.sensitiveWords){if(typeof r=="string"){if(!r||r.length>8192)throw new Error;continue}if(!r||typeof r!="object")throw new Error;if(r.type==="literal"){if(Object.keys(r).some(t=>!["type","value"].includes(t))||typeof r.value!="string"||!r.value||r.value.length>8192)throw new Error}else if(r.type==="regex"){if(Object.keys(r).some(i=>!["type","pattern","flags"].includes(i))||typeof r.pattern!="string"||!r.pattern||r.pattern.length>8192||r.flags!==void 0&&(typeof r.flags!="string"||!/^[gimsu]*$/.test(r.flags)))throw new Error;let t=new RegExp(r.pattern,r.flags??"")}else throw new Error}return e}catch{throw new Error(w)}}function J(o,e=!1){let r;try{if(e){ne(se(o),{recursive:!0,mode:448});try{let n=W(o,O.O_WRONLY|O.O_CREAT|O.O_EXCL,384);try{ie(n,JSON.stringify(_,null,2)+`
|
|
3
|
+
`)}finally{F(n)}}catch(n){if(n.code!=="EEXIST")throw n}}r=W(o,O.O_RDONLY|O.O_NOFOLLOW|O.O_NONBLOCK);let t=te(r);if(!t.isFile()||t.nlink!==1||t.size>1024*1024||process.getuid&&t.uid!==process.getuid())throw new Error;process.platform!=="win32"&&re(r,384);let i=oe(r,"utf8");if(Buffer.byteLength(i)>1024*1024)throw new Error;return{config:T(i),raw:i}}catch{throw new Error(w)}finally{r!==void 0&&F(r)}}var A=/^protecter(?:\.[a-f0-9]{32})?\.json$/;function de(o){return new Promise((e,r)=>{let t=new ue(le(new URL("./validate-worker.mjs",import.meta.url)),{workerData:o,execArgv:[],resourceLimits:{maxOldGenerationSizeMb:64}}),i=!1,n=u=>{i||(i=!0,clearTimeout(s),t.terminate(),u?e():r(new Error(w)))},s=setTimeout(()=>n(!1),2e3);t.once("message",u=>n(u===!0)),t.once("error",()=>n(!1)),t.once("exit",()=>n(!1))})}function ge(o){let e=new Set,r=[];for(let i of o)for(let n of i.sensitiveWords){let s=typeof n=="string"?JSON.stringify(["literal",n]):n.type==="literal"?JSON.stringify(["literal",n.value]):JSON.stringify(["regex",n.pattern,[...new Set((n.flags??"")+"g")].sort().join("")]);e.has(s)||(e.add(s),r.push(n))}let t=JSON.stringify({version:1,sensitiveWords:r});if(Buffer.byteLength(t)>1024*1024)throw new Error(w);return T(t)}function j(o){let e=l.lstatSync(o,{bigint:!0});if(!e.isFile()||e.nlink!==1n||process.getuid&&e.uid!==BigInt(process.getuid()))throw new Error(w);return`${e.dev}:${e.ino}:${e.size}:${e.mtimeNs}`}function I(o,e){let r=v(o,e),t=j(r),i=J(r);if(j(r)!==t)throw new Error(w);return{name:e,...i,identity:t,digest:ce("sha256").update(i.raw).digest("hex")}}function b(o,e){let r=I(o,e.name);if(r.identity!==e.identity||r.digest!==e.digest)throw new Error(w)}function N(o){return l.readdirSync(o).filter(e=>A.test(e)).sort()}function U(o){if(process.platform==="win32")return;let e=l.openSync(o,l.constants.O_RDONLY);try{l.fsyncSync(e)}finally{l.closeSync(e)}}async function z(o,e,r={}){if(!/^[a-f0-9]{32}$/.test(e))throw new Error(w);let t=ae(o),i=`protecter.${e}.json`,n=v(t,i);l.mkdirSync(t,{recursive:!0,mode:448});let s=v(t,".protecter-migration.lock"),u=Date.now()+(r.lockTimeoutMs??5e3);for(;;)try{l.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=u)throw new Error("SPI Protecter: migration locked; check active processes / 迁移锁被占用,请检查活动进程。");await new Promise(g=>setTimeout(g,50))}let a;try{let d=N(t);if(d.length>64)throw new Error(w);let c=[...d].sort((h,D)=>h===i?-1:D===i?1:h.localeCompare(D)).map(h=>I(t,h)),m=ge(c.length?c.map(h=>h.config):[_]);if(await de(c.length?c.map(h=>h.config):[m]),c.length===1&&c[0].name===i){if(b(t,c[0]),JSON.stringify(N(t))!==JSON.stringify(d))throw new Error(w);return{path:n,config:c[0].config,raw:c[0].raw,sourceRaws:[c[0].raw],migrated:0}}let p=JSON.stringify(m,null,2)+`
|
|
4
|
+
`;if(Buffer.byteLength(p)>1024*1024)throw new Error(w);a=v(t,`.protecter-migration.${fe()}.tmp`);let y=l.openSync(a,l.constants.O_WRONLY|l.constants.O_CREAT|l.constants.O_EXCL,384);try{l.writeFileSync(y,p),l.fsyncSync(y)}finally{l.closeSync(y)}if(r.checkpoint?.("beforeCommit",n),JSON.stringify(N(t))!==JSON.stringify(d))throw new Error(w);for(let h of c)b(t,h);l.renameSync(a,n),a=void 0,U(t);let E=I(t,i);if(E.raw!==p)throw new Error(w);r.checkpoint?.("afterCommit",n);for(let h of c)h.name!==i&&(r.checkpoint?.("beforeDelete",v(t,h.name)),b(t,E),b(t,h),l.unlinkSync(v(t,h.name)));U(t);let R=[i];if(JSON.stringify(N(t))!==JSON.stringify(R))throw new Error(w);return b(t,E),{path:n,config:m,raw:p,sourceRaws:[...new Set([...c.map(h=>h.raw),p])],migrated:c.filter(h=>h.name!==i).length}}catch{throw new Error(w)}finally{if(a)try{l.unlinkSync(a)}catch{}l.rmdirSync(s)}}import{createHash as me}from"node:crypto";import{readFileSync as pe}from"node:fs";import{execFileSync as B}from"node:child_process";var G="SPI Protecter: machine ID unavailable / 无法获取机器标识。";function L(o){let e=o.trim().toLowerCase().replace(/-/g,"");if(!/^[a-f0-9]{32}$/.test(e)||/^0+$/.test(e))throw new Error(G);return me("sha256").update(`pi-information-protecter:v1:${e}`).digest("hex").slice(0,32)}function q(){try{if(process.platform==="linux")return L(pe("/etc/machine-id","utf8"));if(process.platform==="darwin"){let o=B("/usr/sbin/ioreg",["-rd1","-c","IOPlatformExpertDevice"],{encoding:"utf8",timeout:5e3,maxBuffer:1048576,stdio:["ignore","pipe","pipe"]});return L(o.match(/"IOPlatformUUID"\s*=\s*"([a-fA-F0-9-]+)"/)?.[1]??"")}if(process.platform==="win32"){let o=B("reg.exe",["query","HKLM\\SOFTWARE\\Microsoft\\Cryptography","/v","MachineGuid","/reg:64"],{encoding:"utf8",timeout:5e3,maxBuffer:65536,stdio:["ignore","pipe","pipe"]});return L(o.match(/MachineGuid\s+REG_SZ\s+([a-fA-F0-9-]+)/i)?.[1]??"")}}catch{}throw new Error(G)}import*as f from"node:fs";import{randomUUID as he}from"node:crypto";var k="SPI Protecter: audit unavailable; request blocked / 审计日志不可用,请求已阻止。",we=32*1024*1024,ye=8*1024*1024;function Se(o){let e=r=>String(r).padStart(2,"0");return`${o.getFullYear()}-${e(o.getMonth()+1)}-${e(o.getDate())} ${e(o.getHours())}:${e(o.getMinutes())}:${e(o.getSeconds())}`}function H(o,e){let r=f.openSync(o,(e?f.constants.O_RDWR|f.constants.O_CREAT:f.constants.O_RDONLY)|f.constants.O_NOFOLLOW|f.constants.O_NONBLOCK,384);try{let t=f.fstatSync(r);if(!t.isFile()||t.nlink!==1||process.getuid&&t.uid!==process.getuid())throw new Error;return process.platform!=="win32"&&f.fchmodSync(r,384),r}catch{throw f.closeSync(r),new Error(k)}}async function X(o,e,r){if(!r.length)return;let t=new Date,i=he(),n=Buffer.from(r.map(([d,g])=>JSON.stringify({version:1,time:Se(t),timezoneOffset:-t.getTimezoneOffset(),requestId:i,provider:e||"unknown",original:g,replacement:d})).join(`
|
|
5
|
+
`)+`
|
|
6
|
+
`);if(n.length>ye)throw new Error(k);let s=`${o}.lock`,u=Date.now()+2e3;for(;;)try{f.mkdirSync(s,{mode:448});break}catch(d){if(d.code!=="EEXIST"||Date.now()>=u)throw new Error(k);await new Promise(g=>setTimeout(g,25))}let a;try{a=H(o,!0);let d=f.fstatSync(a).size;if(d+n.length>we)throw new Error;if(d){let g=Buffer.alloc(1);if(f.readSync(a,g,0,1,d-1),g[0]!==10)throw new Error}try{let g=0;for(;g<n.length;){let c=f.writeSync(a,n,g,n.length-g,d+g);if(!c)throw new Error;g+=c}f.fsyncSync(a)}catch{throw f.ftruncateSync(a,d),f.fsyncSync(a),new Error}}catch{throw new Error(k)}finally{a!==void 0&&f.closeSync(a),f.rmdirSync(s)}}function Y(o,e=20){if(!Number.isInteger(e)||e<1||e>100)throw new Error(k);let r;try{try{r=H(o,!1)}catch(m){if(m.code==="ENOENT")return{records:[],truncated:!1};throw m}let t=f.fstatSync(r).size,i=Math.max(0,t-1024*1024),n=Buffer.alloc(t-i),s=f.readSync(r,n,0,n.length,i),u=n.subarray(0,s).toString("utf8");if(i){let m=u.indexOf(`
|
|
7
|
+
`);u=m<0?"":u.slice(m+1)}let a=u.split(`
|
|
8
|
+
`),d=a.pop()!=="",g=[],c=!1;for(let m of a.slice(-e))try{let p=JSON.parse(m);if(p.version!==1||typeof p.time!="string"||typeof p.provider!="string"||typeof p.original!="string"||typeof p.replacement!="string"||typeof p.requestId!="string"||typeof p.timezoneOffset!="number")throw new Error;g.push(p)}catch{c=!0}return{records:g,truncated:i>0||a.length>e||d||c}}catch{throw new Error(k)}finally{r!==void 0&&f.closeSync(r)}}var S="SPI Protecter: request cleared; check config, audit log, scan limits or attachments / 请求已清空,请检查配置、审计日志、扫描限制或附件。",P=class{constructor(e,r=2e3,t=q){this.configDir=e;this.timeoutMs=r;this.machineId=t}configDir;timeoutMs;machineId;tokens=new Map;queue=Promise.resolve();workers=new Set;closed=!1;snapshot;async initialize(){if(this.closed)throw new Error(S);this.snapshot=void 0;let e=await z(this.configDir,this.machineId());if(this.closed)throw new Error(S);this.snapshot=e}get configPath(){return this.snapshot?.path??""}get auditPath(){return this.configPath?this.configPath.replace(/\.json$/,".jsonl"):""}auditRecords(e=20){if(!this.ready)throw new Error(S);return Y(this.auditPath,e)}get ruleCount(){return this.snapshot?.config.sensitiveWords.length??0}get ready(){return!!this.snapshot&&!this.closed}get mappingCount(){return this.tokens.size}redact(e,r="unknown"){let t=this.queue.then(()=>this.scan(e,r));return this.queue=t.catch(()=>{}),t}async scan(e,r){if(!this.ready)throw new Error(S);let{config:t,sourceRaws:i}=this.snapshot,n=JSON.stringify(e);if(!n||Buffer.byteLength(n)>8*1024*1024)throw new Error(S);let s;try{s=JSON.parse(n)}catch{throw new Error(S)}let u=await new Promise((a,d)=>{let g=!1,c=new Ee(Oe(new URL("./scan-worker.mjs",import.meta.url)),{workerData:{payload:s,rules:t.sensitiveWords,entries:[...this.tokens],configRaws:i},execArgv:[],resourceLimits:{maxOldGenerationSizeMb:128}});this.workers.add(c);let m=y=>{if(!g){if(g=!0,clearTimeout(p),this.workers.delete(c),c.terminate(),!y||this.closed){d(new Error(S));return}a(y)}},p=setTimeout(()=>m(),this.timeoutMs);c.once("message",y=>m(y.failed?void 0:y)),c.once("error",()=>m()),c.once("exit",()=>m())});if(this.closed)throw new Error(S);if(await X(this.auditPath,r,u.hits),this.closed)throw new Error(S);for(let[a,d]of u.additions)this.tokens.set(a,d);return u.payload}restoreText(e){return e.replace(/__PIP_[a-f0-9]{48}__/g,r=>this.tokens.get(r)??r)}restore(e,r=0){if(r>80)throw new Error("protecter: 响应层级过深。");if(typeof e=="string")return this.restoreText(e);if(Array.isArray(e))return e.map(t=>this.restore(t,r+1));if(e&&typeof e=="object"){let t=Object.create(null);for(let[i,n]of Object.entries(e)){let s=this.restoreText(i);if(Object.hasOwn(t,s))throw new Error("protecter: 响应键冲突。");t[s]=this.restore(n,r+1)}return t}return e}close(){this.closed=!0,this.snapshot=void 0,this.tokens.clear();for(let e of this.workers)e.terminate();this.workers.clear()}};import{realpathSync as ve,statSync as K,readdirSync as ke}from"node:fs";import{homedir as Re}from"node:os";import{basename as $,dirname as x,isAbsolute as be,relative as V,resolve as C}from"node:path";import{fileURLToPath as xe}from"node:url";function Ne(o,e){let r=o.replace(/^@/,"");return r.startsWith("file://")&&(r=xe(r)),(r==="~"||r.startsWith("~/"))&&(r=Re()+r.slice(1)),C(e,r)}function M(o){try{return ve(o)}catch{let e=x(o);return e===o?o:C(M(e),V(e,o))}}function Pe(o,e){let r=V(o,e);return r===""||!r.startsWith("..")&&!be(r)}function Q(o,e){try{let r=K(o),t=K(e);return r.dev===t.dev&&r.ino===t.ino}catch{return!1}}function Z(o,e,r,t){if(!t)return!0;let i=M(C(t));if(["bash","powershell"].includes(o)){let u=typeof e.command=="string"?e.command:"";if(/protecter(?:\.[a-f0-9]{32})?\.jsonl?|\.protecter-migration/i.test(u)||u.includes(t))return!0}let n=e.path??e.file_path??e.filePath,s=["grep","find","ls"].includes(o);if(typeof n!="string"&&!s)return!1;try{let u=Ne(typeof n=="string"?n:".",r),a=M(u),d=/^protecter\.[a-f0-9]{32}\.jsonl(?:\.lock)?$/,g=ke(x(i)).filter(c=>d.test(c)).some(c=>Q(u,C(x(i),c)));return x(a)===x(i)&&(A.test($(a))||d.test($(a))||$(a).startsWith(".protecter-migration"))||a===i||Q(u,t)||g||s&&Pe(a,i)}catch{return!0}}function _e(o){let e=new P(Ce()),r=/^0\.85\./.test(ee)&&Number(ee.split(".")[2])>=1,t=!1;o.on("session_start",async(i,n)=>{try{if(!r)throw new Error;await e.initialize(),t=!0;let s=e.ruleCount;n.ui.setStatus("protecter",`SPI Protecter · ${s} rules`),s===0&&n.ui.notify("SPI Protecter: empty rules; edit local machine config and /reload / 规则为空,请编辑本地机器配置后重载。","warning")}catch{t=!1,n.ui.notify(S,"error")}}),o.on("before_provider_request",async(i,n)=>{try{if(!r)throw new Error;let s=await e.redact(i.payload,n.model?.provider??"unknown");return t=!0,s}catch{t=!1;try{await n.abort()}catch{}try{n.ui.notify(S,"error")}catch{}return{}}}),o.on("message_end",i=>{if(i.message.role!=="assistant")return;let n=i.message;return{message:{...n,content:n.content.map(s=>s.type==="text"?{...s,text:e.restoreText(s.text)}:s.type==="thinking"?{...s,thinking:e.restoreText(s.thinking)}:s.type==="toolCall"?{...s,arguments:e.restore(s.arguments)}:s),...n.errorMessage?{errorMessage:e.restoreText(n.errorMessage)}:{}}}}),o.registerMarkdownTransformer((i,n)=>n.messageType==="user"?i:e.restoreText(i)),o.on("tool_call",(i,n)=>{if(!t)return{block:!0,reason:"SPI Protecter 尚未就绪。"};let s=e.restore(i.input);for(let u of Object.keys(i.input))delete i.input[u];if(Object.assign(i.input,s),Z(i.toolName,i.input,n.cwd,e.configPath))return{block:!0,reason:"SPI Protecter:禁止工具访问保护配置,请由用户在本地编辑。"}}),o.on("session_before_compact",(i,n)=>(n.ui.notify("SPI Protecter:暂不支持远程上下文压缩,请使用 /new 开始新会话。","warning"),{cancel:!0})),o.on("session_before_tree",i=>i.preparation.userWantsSummary?{cancel:!0}:void 0),o.registerCommand("protecter",{description:"View local protection records / 查看本地保护记录",handler:async(i,n)=>{let s=i.trim().split(/\s+/).filter(Boolean),u=s[0]??"logs";if(!["status","reload","logs"].includes(u)||u!=="logs"&&s.length>1||s.length>2||s[1]!==void 0&&!/^(?:[1-9]\d?|100)$/.test(s[1])){n.ui.notify("Usage / 用法: /protecter [logs [1-100]|status|reload]. No secrets in arguments / 不要在参数中输入秘密。","info");return}if(u==="logs"){if(n.mode!=="tui"){n.ui.notify("Local TUI required / 请在本地 TUI 查看记录。","warning");return}try{let{records:a,truncated:d}=e.auditRecords(Number(s[1]??20));if(!a.length){n.ui.notify("No readable records / 暂无可读记录。","info");return}let g=E=>JSON.stringify(E).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,R=>`\\u${R.charCodeAt(0).toString(16).padStart(4,"0")}`),c=a.map((E,R)=>`${R+1}. ${g(E.time)} | ${g(E.provider).slice(0,120)}`),m=await n.ui.select(`Protection records / 保护记录${d?" (limited tail / 有限尾部)":""}`,c),p=m===void 0?-1:c.indexOf(m);if(p<0||!await n.ui.confirm("Sensitive plaintext / 敏感明文","Reveal locally? Never share this view / 确认本地显示?请勿分享此界面。"))return;let y=g(a[p]);await n.ui.editor("Local preview; edits discarded / 本地预览,编辑不保存",y.length>2e4?y.slice(0,2e4)+`
|
|
9
|
+
[Preview truncated / 预览截断]`:y)}catch{n.ui.notify("Cannot read audit records / 无法读取审计记录。","error")}return}if(u==="reload"){await n.waitForIdle(),await n.reload();return}n.ui.notify(`SPI Protecter: ${e.ready?"ready / 就绪":"not ready / 未就绪"}; ${e.ruleCount} rules / 规则; ${e.mappingCount} mappings / 映射. Memory snapshot; /reload to refresh / 内存快照,重载后更新。`,e.ready?"info":"error")}}),o.on("session_shutdown",()=>e.close())}export{_e as default};
|
package/dist/scan-worker.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/*! SPI Protecter | MIT | see LICENSE / SPI 隐私保护插件 | MIT 许可 */
|
|
2
|
-
import{parentPort as
|
|
2
|
+
import{parentPort as x,workerData as k}from"node:worker_threads";import{randomBytes as M}from"node:crypto";try{let _=function(t){let e=l.get(t);if(e)return e;if(c.size>=5e4)throw new Error;do e=`__PIP_${M(24).toString("hex")}__`;while(c.has(e)||j.includes(e));return l.set(t,e),c.set(e,t),w.push([e,t]),e},h=function(t){let e=[];for(let o of p)if(o.literal===void 0){o.regex.lastIndex=0;for(let n of t.matchAll(o.regex)){if(!n[0].length)throw new Error;if(e.push([n.index,n.index+n[0].length]),++u>1e5)throw new Error}}else{if(!o.literal)continue;let n=0;for(;(n=t.indexOf(o.literal,n))!==-1;){if(e.push([n,n+o.literal.length]),++u>1e5)throw new Error;n++}}e.sort((o,n)=>o[0]-n[0]||n[1]-o[1]);let r=[];for(let o of e){let n=r.at(-1);n&&o[0]<n[1]?n[1]=Math.max(n[1],o[1]):r.push([...o])}let s="",i=0;for(let[o,n]of r){let S=_(t.slice(o,n));m.add(S),s+=t.slice(i,o)+S,i=n}return s+t.slice(i)},f=function(t){let e="",r=0;for(let s of t.matchAll(/__PIP_[a-f0-9]{48}__/g))c.has(s[0])&&(e+=h(t.slice(r,s.index))+s[0],r=s.index+s[0].length);return e+h(t.slice(r))},a=function(t,e=0){if(++J>2e5||e>80)throw new Error;if(typeof t=="string"){if(y.some(r=>t.includes(r)))return f(t);if(/^\s*[[{]/.test(t)){let r;try{r=JSON.parse(t)}catch{}if(r&&typeof r=="object"){let s=JSON.stringify(a(r,e+1)),i=f(s);return i===s?s:(JSON.parse(i),i)}}return f(t)}if(typeof t=="number"){let r=f(String(t));return r===String(t)?t:r}if(Array.isArray(t))return t.map(r=>a(r,e+1));if(t&&typeof t=="object"){if(typeof t.type=="string"&&/image|audio|video|document|(^|_)file($|_)/i.test(t.type))throw new Error;if(Object.keys(t).some(s=>/^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(s)))throw new Error;let r=Object.create(null);for(let[s,i]of Object.entries(t)){let o=f(s);if(Object.hasOwn(r,o))throw new Error;r[o]=a(i,e+1)}return r}if(t===null||typeof t=="boolean")return t;throw new Error};A=_,P=h,D=f,I=a;let{payload:d,rules:E,entries:g,configRaws:b}=k,l=new Map(g.map(([t,e])=>[e,t])),c=new Map(g),w=[],m=new Set,p=E.map(t=>typeof t=="string"?{literal:t}:t.type==="literal"?{literal:t.value}:{regex:new RegExp(t.pattern,[...new Set((t.flags??"")+"g")].join(""))});for(let t of l.keys())p.push({literal:t});let y=[...new Set(b.flatMap(t=>[t,JSON.stringify(JSON.parse(t))]))];for(let t of y)p.push({literal:t});let j=JSON.stringify(d),u=0,J=0,O=a(d),N=JSON.stringify(O),T=[...m].filter(t=>N.includes(t)).map(t=>[t,c.get(t)]);x.postMessage({payload:O,additions:w,hits:T})}catch{x.postMessage({failed:!0})}var A,P,D,I;
|