@chatpanel/pii 0.2.10 → 0.2.11
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/package.json +1 -1
- package/pii-redact.js +64 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/pii",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "The canonical ChatPanel privacy engine — reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/pii-redact.js
CHANGED
|
@@ -72,6 +72,42 @@ function escapeRegex(s) {
|
|
|
72
72
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Apply many find/replace rules in a SINGLE left-to-right pass over the source.
|
|
76
|
+
// Each rule is { re: <global RegExp>, repl: (match) => string }. Unlike running
|
|
77
|
+
// rule[0].replace then rule[1].replace then …, text emitted by one rule is NEVER
|
|
78
|
+
// re-scanned by a later rule — so substitutions can't cascade (e.g. a pseudonym
|
|
79
|
+
// that happens to equal another entry's input). On a tie at the same position the
|
|
80
|
+
// earlier rule wins (rules carry priority by their order in the array).
|
|
81
|
+
function applyRulesOnce(text, rules) {
|
|
82
|
+
if (!rules || rules.length === 0) return text;
|
|
83
|
+
let out = '';
|
|
84
|
+
let pos = 0;
|
|
85
|
+
const n = text.length;
|
|
86
|
+
while (pos <= n) {
|
|
87
|
+
let best = null;
|
|
88
|
+
let bestRule = null;
|
|
89
|
+
for (const rule of rules) {
|
|
90
|
+
rule.re.lastIndex = pos;
|
|
91
|
+
const m = rule.re.exec(text);
|
|
92
|
+
if (m && (best === null || m.index < best.index)) {
|
|
93
|
+
best = m;
|
|
94
|
+
bestRule = rule;
|
|
95
|
+
if (m.index === pos) break; // nothing can start earlier than the cursor
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!best) { out += text.slice(pos); break; }
|
|
99
|
+
out += text.slice(pos, best.index);
|
|
100
|
+
if (best[0].length === 0) { // pathological empty match — emit a char, advance
|
|
101
|
+
out += text[best.index] ?? '';
|
|
102
|
+
pos = best.index + 1;
|
|
103
|
+
} else {
|
|
104
|
+
out += bestRule.repl(best);
|
|
105
|
+
pos = best.index + best[0].length;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
75
111
|
function luhnValid(digits) {
|
|
76
112
|
let sum = 0;
|
|
77
113
|
let alt = false;
|
|
@@ -135,25 +171,33 @@ export function redactText(text, vault, {
|
|
|
135
171
|
// An entry with `alias` PSEUDONYMIZES: permanent substitution (the model and
|
|
136
172
|
// the user's transcript both see the alias, never reversed). Otherwise it
|
|
137
173
|
// REDACTS to a reversible [[TYPE_n]] placeholder restored in the user's view.
|
|
174
|
+
// All entries are applied in ONE pass (applyRulesOnce): an alias produced by
|
|
175
|
+
// one entry must not be re-matched by a later entry, or substitutions cascade
|
|
176
|
+
// (e.g. value 'Arnav'→alias 'John' then 'John' caught by a later 'John' rule).
|
|
177
|
+
const dictRules = [];
|
|
138
178
|
for (const d of dictionary || []) {
|
|
139
179
|
if (!d) continue;
|
|
180
|
+
let re;
|
|
140
181
|
try {
|
|
141
|
-
|
|
182
|
+
re = d.pattern
|
|
142
183
|
? new RegExp(d.pattern, d.flags && /g/.test(d.flags) ? d.flags : `${d.flags || ''}g`)
|
|
143
184
|
: (d.value ? new RegExp(`(?<![\\w])${escapeRegex(d.value)}(?![\\w])`, 'gi') : null);
|
|
144
|
-
if (!re) continue;
|
|
145
|
-
if (d.alias != null && d.alias !== '') {
|
|
146
|
-
out = out.replace(re, () => d.alias); // pseudonymize: model + reply see the alias…
|
|
147
|
-
// …but record alias→original so LOCAL tool args (history/meeting search) map
|
|
148
|
-
// back to the real value. Local lookups must hit real data; only the model is blinded.
|
|
149
|
-
if (d.value) v.aliases.set(d.alias, d.value);
|
|
150
|
-
} else {
|
|
151
|
-
out = out.replace(re, (m) => tokenFor(v, d.type || (d.pattern ? 'PII' : 'TERM'), d.pattern ? m : d.value));
|
|
152
|
-
}
|
|
153
185
|
} catch {
|
|
154
|
-
|
|
186
|
+
re = null; // a bad user regex must never break redaction
|
|
187
|
+
}
|
|
188
|
+
if (!re) continue;
|
|
189
|
+
if (d.alias != null && d.alias !== '') {
|
|
190
|
+
// pseudonymize: model + reply see the alias…
|
|
191
|
+
// …but record alias→original so LOCAL tool args (history/meeting search) map
|
|
192
|
+
// back to the real value. Local lookups must hit real data; only the model is blinded.
|
|
193
|
+
if (d.value) v.aliases.set(d.alias, d.value);
|
|
194
|
+
dictRules.push({ re, repl: () => d.alias });
|
|
195
|
+
} else {
|
|
196
|
+
const type = d.type || (d.pattern ? 'PII' : 'TERM');
|
|
197
|
+
dictRules.push({ re, repl: (m) => tokenFor(v, type, d.pattern ? m[0] : d.value) });
|
|
155
198
|
}
|
|
156
199
|
}
|
|
200
|
+
out = applyRulesOnce(out, dictRules);
|
|
157
201
|
|
|
158
202
|
// 2) Known entities (full tier) — longest value first so "Alex Rivera" wins
|
|
159
203
|
// before a bare "Alex". Restores to the canonical entity value.
|
|
@@ -188,9 +232,15 @@ export function restoreText(text, vault) {
|
|
|
188
232
|
export function restoreWithAliases(text, vault) {
|
|
189
233
|
let out = restoreText(text, vault);
|
|
190
234
|
if (vault?.aliases?.size) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
235
|
+
// ONE pass over every alias at once. Looping `replace` per alias re-scans the
|
|
236
|
+
// output and cascades when one alias's real value equals another alias (e.g.
|
|
237
|
+
// 'Twinkle'→'John' then 'John'→'Arnav'): the model's "Twinkle" would walk the
|
|
238
|
+
// chain to "Arnav". A single alternation replaces each span exactly once.
|
|
239
|
+
// Longest alias first so a multi-word pseudonym wins over its prefix.
|
|
240
|
+
const aliases = [...vault.aliases.keys()].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
241
|
+
if (aliases.length) {
|
|
242
|
+
const re = new RegExp(`(?<![\\w])(?:${aliases.map(escapeRegex).join('|')})(?![\\w])`, 'g');
|
|
243
|
+
out = out.replace(re, (m) => (vault.aliases.has(m) ? vault.aliases.get(m) : m));
|
|
194
244
|
}
|
|
195
245
|
}
|
|
196
246
|
return out;
|