@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.10",
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
- const re = d.pattern
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
- /* a bad user regex must never break redaction */
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
- for (const [alias, real] of vault.aliases) {
192
- if (!alias) continue;
193
- out = out.replace(new RegExp(`(?<![\\w])${escapeRegex(alias)}(?![\\w])`, 'g'), () => real);
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;