pi-information-protecter 0.6.2 → 0.6.3
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 +14 -0
- package/README.md +30 -0
- package/SECURITY.md +2 -0
- package/dist/scan-worker.mjs +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog / 更新日志
|
|
2
2
|
|
|
3
|
+
## 0.6.3 — Conversations no longer block after a rule learns a value / 规则学习到值后不再拦截会话
|
|
4
|
+
|
|
5
|
+
### Fixed / 修复
|
|
6
|
+
|
|
7
|
+
- Stop blocking a conversation once a regex rule has learned a value. Since 0.6.0 a replacement keeps the shape of what the rule matches, so the rule matches its own replacement in later turns; the alias-crossing guard treated that as a crossing and blocked every following request with `FIXED_ALIAS_CROSSING`. Only a match that straddles a replacement boundary is a crossing now; a match that is, or sits inside, a replacement is expected and skipped.
|
|
8
|
+
正则规则学习到值之后不再拦截整个会话。0.6.0 起替换值与规则所匹配的内容同形,因此后续轮次中规则会匹配到自身的替换值;别名跨越防护把这种情况当作跨越,使之后每个请求都以 `FIXED_ALIAS_CROSSING` 被拦。现在只有横跨替换值边界的匹配才算跨越,匹配到替换值本身或其内部属于预期并跳过。
|
|
9
|
+
- Add a regression covering later turns that carry a learned replacement in history, while keeping the existing straddling-match rejection.
|
|
10
|
+
新增回归,覆盖历史中带有已学习替换值的后续轮次,同时保留对横跨匹配的拒绝。
|
|
11
|
+
|
|
12
|
+
### Documentation / 文档
|
|
13
|
+
|
|
14
|
+
- Document that the model reasons about the replacement, so answers derived from a protected value, such as a digit position or a checksum, are wrong even though the value itself is restored in the reply.
|
|
15
|
+
文档说明模型推理的是替换值,因此从受保护值推导出的答案(如第几位、校验位)是错的,尽管值本身在回复中会被还原。
|
|
16
|
+
|
|
3
17
|
## 0.6.2 — Numeric replacement retry and restoration / 数值替换重试与还原
|
|
4
18
|
|
|
5
19
|
### Fixed / 修复
|
package/README.md
CHANGED
|
@@ -105,6 +105,21 @@ Letters and digits are replaced inside their own writing system: ASCII, Latin-1,
|
|
|
105
105
|
|
|
106
106
|
**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.
|
|
107
107
|
|
|
108
|
+
**The model reasons about the replacement, so answers derived from a protected value are wrong.** Asking for the third digit of a protected ID returns the third digit of the replacement, and only the value itself is restored in the reply, never the conclusion drawn from it. The answer therefore looks confident and consistent but does not match the restored value. An opaque token made this obvious, because the model could only report that it could not read the value. Do not ask the model about digits, checksums, ordering, arithmetic or comparisons involving protected values; compute those locally.
|
|
109
|
+
|
|
110
|
+
Worked example, asking for the third digit of a protected phone number:
|
|
111
|
+
|
|
112
|
+
| Step | Value |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| What you typed | `13800138000` — third digit is `8` |
|
|
115
|
+
| What the provider received | `27431905882` — third digit is `4` |
|
|
116
|
+
| What the model answered | "the third digit is `4`" |
|
|
117
|
+
| What you see after restoration | `13800138000` … "the third digit is `4`" |
|
|
118
|
+
|
|
119
|
+
The number is restored, the reasoning is not, so the reply quietly contradicts itself. Nothing flags this: a derived answer is ordinary text, indistinguishable from any other sentence. The reply is also consistent across retries, because the same value keeps the same replacement, so re-asking does not reveal the error.
|
|
120
|
+
|
|
121
|
+
If a field must be reasoned about correctly, either leave it unprotected, or give it a fixed `replacement` and accept that the alias is semantically recognisable. There is no option that both hides a value and lets the model compute over it.
|
|
122
|
+
|
|
108
123
|
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.
|
|
109
124
|
|
|
110
125
|
### Message language
|
|
@@ -305,6 +320,21 @@ pi -e ./src/index.ts
|
|
|
305
320
|
|
|
306
321
|
**这是有意为之的权衡。** 不透明占位符仅暴露“发生了脱敏”;同形值还会暴露长度、字符类别和标点结构,并且在模型看来像真实数据。不支持的文字体系会原样保留,因此匹配这类文本的规则可能残留部分可见内容。
|
|
307
322
|
|
|
323
|
+
**模型推理的是替换值,因此从受保护值推导出的答案是错的。** 询问受保护身份证号的第 3 位,得到的是替换值的第 3 位;回复中只有值本身会被还原,由它推导出的结论不会。因此答案看似笃定且自洽,却与还原后的值对不上。不透明占位符不会有这个问题,因为模型只能表示自己读不到该值。请勿让模型回答涉及受保护值的位数、校验位、排序、算术或比较,这类计算应在本地完成。
|
|
324
|
+
|
|
325
|
+
实例:询问受保护手机号的第 3 位。
|
|
326
|
+
|
|
327
|
+
| 环节 | 值 |
|
|
328
|
+
| --- | --- |
|
|
329
|
+
| 你输入的内容 | `13800138000`——第 3 位是 `8` |
|
|
330
|
+
| 提供商实际收到的 | `27431905882`——第 3 位是 `4` |
|
|
331
|
+
| 模型的回答 | “第 3 位是 `4`” |
|
|
332
|
+
| 还原后你看到的 | `13800138000` … “第 3 位是 `4`” |
|
|
333
|
+
|
|
334
|
+
号码被还原了,推理没有,因此回复会静默地自相矛盾。没有任何机制能发现它:推导出的答案就是普通文本,与其他句子无法区分。重试也不会暴露错误,因为同一值始终对应同一替换值,结果前后一致。
|
|
335
|
+
|
|
336
|
+
如果某个字段必须被正确推理,要么不保护它,要么为它设置固定 `replacement` 并接受别名在语义上可识别。不存在既隐藏值、又能让模型对它运算的方案。
|
|
337
|
+
|
|
308
338
|
为保证还原精确,候选值会被校验:与原文相同、已被占用、或已出现在同一请求中的候选值会被拒绝。无法生成唯一值时(通常是极短匹配),以 `SCAN_UNIQUE_FAILED` 拦截请求。由于同形值与普通内容难以区分,若模型自行输出了相同的短字符串,它会被还原为原文,因此应优先匹配更长、更有辨识度的值。
|
|
309
339
|
|
|
310
340
|
### 消息语言
|
package/SECURITY.md
CHANGED
|
@@ -21,6 +21,8 @@ Prevent configured personal information from accidentally entering Pi LLM reques
|
|
|
21
21
|
|
|
22
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
23
|
出站文本使用随机同形值替换,运行映射保存在内存,但 0.3.0 新增私有 JSONL 原文与替换值审计。日志属于敏感凭证存储,不是加密归档,禁止上传;退出后仍保留,不用于恢复映射。
|
|
24
|
+
- The model reasons about the replacement, so any answer derived from a protected value, such as a digit position, a checksum, a comparison or arithmetic, is wrong. Only the value is restored in the reply, never the conclusion drawn from it, so the reply looks confident and stays consistent across retries. Verify such answers locally.
|
|
25
|
+
模型推理的是替换值,因此任何从受保护值推导出的答案(位数、校验位、比较、算术)都是错的。回复中只有值被还原,由它推导出的结论不会,因此答案看似笃定且重试结果一致。请在本地核对。
|
|
24
26
|
- 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
27
|
同形替换(0.6.0)保留 JSON 类型、长度、字符类别和标点结构,因此比不透明占位符泄露更多元信息,且看似真实数据。不支持的文字体系和符号原样保留,命中值可能部分可见。模型自行输出相同短字符串时可能被还原,应优先使用较长且有辨识度的目标;无法精确往返的数值以及无唯一替换值的匹配均拒绝放行。
|
|
26
28
|
- 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.
|
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 k,workerData as te}from"node:worker_threads";import{randomBytes as Z}from"node:crypto";var z=[[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]],X=BigInt(Number.MAX_SAFE_INTEGER);function g(n){if(n<=1n)return 0n;let t=n.toString(2).length,o=Math.ceil(t/8),h=(1n<<BigInt(t))-1n;for(let d=0;d<64;d++){let a=0n;for(let x of Z(o))a=a<<8n|BigInt(x);if(a&=h,a<n)return a}return n-1n}function K(n){return z.find(([t,o])=>n>=t&&n<=o)}function Q(n){let t="";for(let o of n){let h=o.codePointAt(0),d=K(h);if(!d){t+=o;continue}let[a,x]=d,S=BigInt(x-a+1);t+=String.fromCodePoint(a+Number(g(S)))}return t}var q=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,v={1:[0n,9n],2:[10n,99n],3:[100n,255n]};function ee(n){if(n.length>1&&n.startsWith("0")){let h="0";for(let d=1;d<n.length;d++)h+=String(g(10n));return h}let[t,o]=v[n.length];return String(t+g(o-t+1n))}function G(n){let t=q.exec(n);if(!(!t||t.slice(1).some(o=>Number(o)>255)))return t.slice(1).map(ee).join(".")}function H(n){let t="";for(let o of n)o>="0"&&o<="9"?t+=String(g(10n)):o>="a"&&o<="f"?t+=String.fromCharCode(97+Number(g(6n))):o>="A"&&o<="F"?t+=String.fromCharCode(65+Number(g(6n))):t+=o;return t}function ne(n){if(!n.includes(":"))return;let t=n.lastIndexOf(":"),o=n.slice(t+1);if(o.includes(".")){let h=G(o);return!h||!/^[0-9A-Fa-f:]*$/.test(n.slice(0,t+1))?void 0:H(n.slice(0,t+1))+h}if(/^[0-9A-Fa-f:]+$/.test(n))return H(n)}function U(n){return G(n)??ne(n)??Q(n)}function V(n,t=!0){if(!/^\d+$/.test(n))return;if(n.length===1)return n==="0"?void 0:String(1n+g(9n));if(n.startsWith("0")){let a="";for(let x of n)a+=x==="0"?"0":String(1n+g(9n));return a}let o=10n**BigInt(n.length-1),h=10n**BigInt(n.length)-1n,d=t&&h>X?X:h;if(!(d<o))return String(o+g(d-o+1n))}function W(n){let t=/^([0-9.+-]*?)([eE][+-]?\d+)?$/.exec(n);if(!t||!/\d/.test(t[1]))return;let o=t[1],h=t[2]??"",d=o.indexOf("."),a=o.search(/\d(?!.*\d)/),x=d===-1?o.length:d,S="";for(let[m,I]of[...o].entries()){if(I<"0"||I>"9"){S+=I;continue}let R=d===-1||m<d,y=R&&!/\d/.test(S),T=!R&&m===a;if(I==="0"&&(y||T)){S+="0";continue}let L=y&&x>1||T;S+=String(L?1n+g(9n):g(10n))}return S+h}function w(n){let t=Number(n);return Number.isFinite(t)&&String(t)===n}var _,F=0;function l(n,t){throw{code:n,rule:_,otherRule:t,node:F}}try{let L=function(e,i,r){for(let f=0;f<64;f++){let c;if(/^\d+$/.test(e)?c=V(e,i):i?c=W(e):c=U(e),!(!c||c===e)&&!a.has(c)&&!d.has(c)&&!R.includes(c)&&!(r&&!r(c)))return c}},j=function(e,i,r=!1,f){let c=d.get(e);return c?(i!==void 0&&c!==i&&l("FIXED_MAPPING_CONFLICT"),c):i!==void 0?(a.size>=5e4&&l("SCAN_MAPPING_LIMIT"),a.has(i)&&a.get(i)!==e&&l("FIXED_ALIAS_REUSED"),i.includes(e)&&l("FIXED_SENSITIVE_TARGET"),d.set(e,i),a.set(i,e),x.push([i,e]),i):(a.size>=5e4&&l("SCAN_MAPPING_LIMIT"),c=L(e,r,f),c||l("SCAN_UNIQUE_FAILED"),d.set(e,c),a.set(c,e),x.push([c,e]),c)},P=function(e,i=!1){let r=[];for(let u of m)if(_=u.rule,u.literal===void 0){u.regex.lastIndex=0;for(let s of e.matchAll(u.regex))s[0].length||l("SCAN_ZERO_WIDTH"),r.push([s.index,s.index+s[0].length,u.replacement,u.rule]),++y>1e5&&l("SCAN_MATCH_LIMIT")}else{if(!u.literal)continue;let s=0;for(;(s=e.indexOf(u.literal,s))!==-1;)r.push([s,s+u.literal.length,u.replacement,u.rule]),++y>1e5&&l("SCAN_MATCH_LIMIT"),s++}r.sort((u,s)=>u[0]-s[0]||s[1]-u[1]);let f=[];for(let u of r){let s=f.at(-1);s&&u[0]<s[1]?((s[2]!==void 0||u[2]!==void 0)&&((s[0]!==u[0]||s[1]!==u[1]||s[2]!==void 0&&u[2]!==void 0&&s[2]!==u[2])&&(_=s[3],l("FIXED_OVERLAP",u[3])),s[2]===void 0&&u[2]!==void 0&&(s[3]=u[3]),s[2]??=u[2]),s[1]=Math.max(s[1],u[1])):f.push([...u])}let c="",A=0;for(let[u,s,p,N]of f){_=N;let D=i?b=>w(e.slice(0,u)+b+e.slice(s)):void 0,M=j(e.slice(u,s),p,i,D);S.add(M),c+=e.slice(A,u)+M,A=s}return c+e.slice(A)},E=function(e,i=!1){let r="",f=0,c=p=>p.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),A=[...a.keys()];if(!A.length)return P(e,i);let u=new RegExp(A.sort((p,N)=>N.length-p.length).map(c).join("|"),"g"),s=[...e.matchAll(u)].filter(p=>a.has(p[0]));for(let p of m)if(!(!p.regex||!A.length)){_=p.rule,p.regex.lastIndex=0;for(let N of e.matchAll(p.regex)){++y>1e5&&l("SCAN_MATCH_LIMIT"),N[0].length||l("SCAN_ZERO_WIDTH");let D=N.index,M=N.index+N[0].length;s.some(b=>{let $=b.index,J=b.index+b[0].length;return D>=J||M<=$?!1:D<$||M>J})&&l("FIXED_ALIAS_CROSSING")}}for(let p of s)a.has(p[0])&&(r+=P(e.slice(f,p.index),i)+p[0],f=p.index+p[0].length);return r+P(e.slice(f),i)},C=function(e,i=0){if(F=++T,_=void 0,(T>2e5||i>80)&&l("SCAN_COMPLEXITY"),typeof e=="string"){if(I.some(r=>e.includes(r)))return E(e);if(/^\s*[[{]/.test(e)){let r;try{r=JSON.parse(e)}catch{}if(r&&typeof r=="object"){let f=JSON.stringify(C(r,i+1)),c=E(f);if(c===f)return f;try{JSON.parse(c)}catch{l("SCAN_JSON_REWRITE")}return c}}return E(e)}if(typeof e=="number"){let r=String(e),f=E(r,!0);return f===r?e:(w(f)||l("SCAN_NUMBER_UNSAFE"),Number(f))}if(Array.isArray(e))return e.map(r=>C(r,i+1));if(e&&typeof e=="object"){typeof e.type=="string"&&/image|audio|video|document|(^|_)file($|_)/i.test(e.type)&&l("SCAN_ATTACHMENT"),Object.keys(e).some(f=>/^(inlineData|inline_data|fileData|file_data|image_url|input_audio)$/.test(f))&&l("SCAN_ATTACHMENT");let r=Object.create(null);for(let[f,c]of Object.entries(e)){let A=E(f);Object.hasOwn(r,A)&&l("SCAN_KEY_COLLISION"),r[A]=C(c,i+1)}return r}if(e===null||typeof e=="boolean")return e;l("PAYLOAD_JSON")},O=function(e,i,r=0){if(r>80&&l("SCAN_COMPLEXITY"),typeof e=="string"){if(e.includes(i))return!0;let f;try{f=JSON.parse(e)}catch{return!1}return f&&typeof f=="object"?O(f,i,r+1):!1}return Array.isArray(e)?e.some(f=>O(f,i,r+1)):e&&typeof e=="object"?Object.entries(e).some(([f,c])=>f.includes(i)||O(c,i,r+1)):!1};re=L,ie=j,se=P,oe=E,fe=C,ce=O;let{payload:n,rules:t,entries:o,configRaws:h}=te,d=new Map(o.map(([e,i])=>[i,e])),a=new Map(o),x=[],S=new Set,m=t.map((e,i)=>{let r=i+1;return typeof e=="string"?{literal:e,rule:r}:e.type==="literal"?{literal:e.value,replacement:e.replacement,rule:r}:{rule:r,replacement:e.replacement,regex:new RegExp(e.pattern,[...new Set((e.flags??"")+"g")].join(""))}});for(let e of d.keys())m.push({literal:e});let I=[...new Set(h.flatMap(e=>[e,JSON.stringify(JSON.parse(e))]))];for(let e of I)m.push({literal:e});let R=JSON.stringify(n),y=0,T=0,B=C(n),Y=[...S].filter(e=>O(B,e)).map(e=>[e,a.get(e)]);k.postMessage({payload:B,additions:x,hits:Y})}catch(n){k.postMessage({failed:!0,diagnostic:{code:typeof n?.code=="string"?n.code:"SCAN_INTERNAL",rule:n?.rule??_,otherRule:n?.otherRule,node:n?.node??F}})}var re,ie,se,oe,fe,ce;
|