@chatpanel/pii 0.4.0 → 0.7.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/pii",
3
- "version": "0.4.0",
4
- "description": "The canonical ChatPanel privacy engine \u2014 reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
3
+ "version": "0.7.1",
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",
7
7
  "exports": {
package/pii-detect.js CHANGED
@@ -39,15 +39,57 @@ export function withTimeout(promise, ms, signal) {
39
39
  });
40
40
  }
41
41
 
42
- // Map common NER labels (spaCy, HF, Presidio) onto our placeholder types.
42
+ // Map common NER labels onto our placeholder types.
43
+ //
44
+ // FOUR VOCABULARIES, not one, and an unmapped label is SILENTLY DROPPED — `keepEntity` sends
45
+ // anything it does not recognise to the digit-count fallback, where a name has no digits and
46
+ // fails. So a missing row here does not degrade redaction, it turns it off for that type,
47
+ // with nothing on screen to say so.
48
+ //
49
+ // That is not hypothetical. The `multilang-pii-ner` model emits the ai4privacy vocabulary —
50
+ // GIVENNAME, SURNAME, TELEPHONENUM, CITY — and none of those were mapped, so selecting it
51
+ // (it is the default in some builds) meant person names sailed through to the model in
52
+ // plaintext while the shield in the composer still read as on. The deterministic detectors
53
+ // kept catching emails and card numbers, which is exactly what made it hard to notice.
54
+ //
55
+ // • spaCy / OntoNotes PER, ORG, GPE, LOC, NORP
56
+ // • HF bert-base-NER PER, ORG, LOC, MISC
57
+ // • Presidio PERSON, PHONE_NUMBER, EMAIL_ADDRESS, US_SSN…
58
+ // • ai4privacy / multilang GIVENNAME, SURNAME, STREET, ZIPCODE, TELEPHONENUM…
59
+ //
60
+ // When adding a model, run one sentence through it and map every label it returns. An
61
+ // unrecognised label is a hole, and it is an invisible one.
43
62
  function normType(t) {
44
63
  const s = String(t || 'ENTITY').toUpperCase().replace(/[^A-Z0-9]/g, '') || 'ENTITY';
45
64
  const map = {
65
+ // People
46
66
  PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON',
47
- ORG: 'ORG', ORGANIZATION: 'ORG',
67
+ GIVENNAME: 'PERSON', FIRSTNAME: 'PERSON', MIDDLENAME: 'PERSON',
68
+ SURNAME: 'PERSON', LASTNAME: 'PERSON', FULLNAME: 'PERSON',
69
+ // Organisations
70
+ ORG: 'ORG', ORGANIZATION: 'ORG', COMPANYNAME: 'ORG', COMPANY: 'ORG',
71
+ // Places. An address PART is still an address — a building number and a postcode
72
+ // identify a household as surely as the street does.
48
73
  GPE: 'LOCATION', LOC: 'LOCATION', LOCATION: 'LOCATION',
49
- NORP: 'GROUP', EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL',
50
- PHONE: 'PHONE', PHONENUMBER: 'PHONE',
74
+ CITY: 'LOCATION', STATE: 'LOCATION', COUNTY: 'LOCATION', COUNTRY: 'LOCATION',
75
+ STREET: 'ADDRESS', BUILDINGNUM: 'ADDRESS', BUILDINGNUMBER: 'ADDRESS',
76
+ ZIPCODE: 'ADDRESS', POSTCODE: 'ADDRESS', SECADDRESS: 'ADDRESS', ADDRESS: 'ADDRESS',
77
+ NORP: 'GROUP',
78
+ // Contact
79
+ EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL',
80
+ PHONE: 'PHONE', PHONENUMBER: 'PHONE', TELEPHONENUM: 'PHONE', PHONEIMEI: 'ID',
81
+ // Numbers that identify a person. These are ALWAYS redacted (see ALWAYS_KEEP), which is
82
+ // the point of naming them rather than leaving them to the digit-count fallback.
83
+ SOCIALNUM: 'SSN', USSSN: 'SSN', SSN: 'SSN',
84
+ CREDITCARDNUMBER: 'CREDITCARD', CREDITCARD: 'CREDITCARD',
85
+ IBAN: 'IBAN', IBANCODE: 'IBAN',
86
+ ACCOUNTNUM: 'ID', ACCOUNTNUMBER: 'ID', TAXNUM: 'ID', IDCARDNUM: 'ID',
87
+ DRIVERLICENSENUM: 'ID', PASSPORTNUM: 'ID', VEHICLEVRM: 'ID',
88
+ // A date of birth identifies; a plain date does not, and small models tag "today".
89
+ DATEOFBIRTH: 'ID', DOB: 'ID',
90
+ // Handles and secrets
91
+ USERNAME: 'ID', USERID: 'ID', IP: 'ID', IPADDRESS: 'ID', MAC: 'ID',
92
+ PASSWORD: 'SECRET', APIKEY: 'SECRET', SECRET: 'SECRET',
51
93
  };
52
94
  return map[s] || s;
53
95
  }
@@ -57,7 +99,9 @@ function normType(t) {
57
99
  // questions still work if "location" is turned off, etc. Numeric/temporal labels
58
100
  // (DATE, CARDINAL, ORDINAL…) are noisy — small NER models tag "today" / "4" — so
59
101
  // they only count when the value is a long digit run (phone/account/ID).
60
- const ALWAYS_KEEP = new Set(['EMAIL', 'PHONE', 'SSN', 'CREDITCARD', 'IBAN', 'ID']);
102
+ // SECRET joined these: a password or a key must never reach a model, and leaving it to the
103
+ // per-category toggles would let "turn off numbers" switch it off.
104
+ const ALWAYS_KEEP = new Set(['EMAIL', 'PHONE', 'SSN', 'CREDITCARD', 'IBAN', 'ID', 'SECRET']);
61
105
  const LOCATION_TYPES = new Set(['LOCATION', 'FAC', 'ADDRESS', 'GROUP', 'NRP']);
62
106
 
63
107
  function keepEntity(value, type, types) {
@@ -200,6 +244,24 @@ const hostOf = (u) => { try { return new URL(String(u)).host; } catch { return '
200
244
  export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis.fetch, strict = false, structured = NO_STRUCTURE, onEgress = null } = {}) {
201
245
  const det = cfg?.detection;
202
246
  if (!det || !det.backend || det.backend === 'off' || !det.url || typeof fetchImpl !== 'function') return [];
247
+ // AN IN-PROCESS DETECTOR SENDS NOTHING ANYWHERE, so the network guard below must not
248
+ // judge it by a URL it never dials.
249
+ //
250
+ // This is not a hypothetical. A host that runs the model in its own process passes a
251
+ // sentinel URL and a fetchImpl that ignores it entirely — and the sentinel failed the
252
+ // http(s) scheme check, threw, and was swallowed by the fail-open path. The result was a
253
+ // detector that reported itself ready, answered its own health route correctly, and
254
+ // contributed NOTHING to a single redaction: names, organisations and places went to the
255
+ // model in full while the UI said full tier.
256
+ //
257
+ // The opt-out is deliberately narrow. It requires the caller to have supplied its OWN
258
+ // fetch, so a `transport: 'in-process'` line in a config file cannot turn the SSRF guard
259
+ // off for a real network address — without an injected transport there is no in-process
260
+ // anything, and the flag is refused rather than honoured.
261
+ const inProcess = det.transport === 'in-process';
262
+ if (inProcess && fetchImpl === globalThis.fetch) {
263
+ throw new Error("detection.transport 'in-process' needs an injected fetch; refusing to treat a network call as in-process");
264
+ }
203
265
  const capped = String(text || '').slice(0, det.maxChars || 8000);
204
266
  if (capped.trim().length < 8) return [];
205
267
  const key = cacheKey(capped, det);
@@ -211,12 +273,12 @@ export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis
211
273
  // only, never cloud metadata. Loopback/LAN allowed — a local NER server / Ollama
212
274
  // is the normal case. A blocked URL fails open (deterministic-only), or surfaces
213
275
  // to the Test button in strict mode.
214
- assertEndpointUrl(det.url);
276
+ if (!inProcess) assertEndpointUrl(det.url);
215
277
  const t0 = Date.now();
216
278
  try {
217
279
  ents = await withTimeout(run(capped, det, signal, fetchImpl, structured), det.timeoutMs || 1500, signal);
218
- report(onEgress, det, capped, t0, ents.length, null);
219
- } catch (e) { report(onEgress, det, capped, t0, 0, e); throw e; }
280
+ if (!inProcess) report(onEgress, det, capped, t0, ents.length, null);
281
+ } catch (e) { if (!inProcess) report(onEgress, det, capped, t0, 0, e); throw e; }
220
282
  } catch (e) {
221
283
  if (strict) throw e; // surface errors to the Test button
222
284
  ents = []; // otherwise fail open — deterministic redaction still applies
package/pii-redact.js CHANGED
@@ -22,6 +22,46 @@ import { stripHidden, confusablesSkeleton } from './sanitize.js';
22
22
 
23
23
  const TOKEN_RE = /\[\[([A-Z][A-Z0-9]*)_(\d+)\]\]/g;
24
24
 
25
+ /**
26
+ * THE TOKEN FORMAT, PUBLISHED — because `[[TYPE_n]]` collides with `[[wikilink]]`.
27
+ *
28
+ * The placeholder grammar was chosen to be visually obvious in a prompt, and it is
29
+ * character-for-character the wikilink syntax notes and briefs use. So `[[PERSON_1]]` in a
30
+ * stored message reads as a link to a page called "PERSON_1", and downstream that became a
31
+ * backlink, a graph node, and a subject with its own page. The name of a person we
32
+ * deliberately did not learn was being filed as a thing we know about.
33
+ *
34
+ * A placeholder is the ABSENCE of an identity. It must never become a link, a subject, a tag
35
+ * or a topic — and it must never be restored into anything derived and persisted, because
36
+ * that would put the PII back on disk in a second place.
37
+ *
38
+ * Exported rather than left private so consumers ASK instead of re-deriving the pattern:
39
+ * this package owns the format, and `CLAUDE.md` lists it as a wire contract that only
40
+ * changes additively.
41
+ */
42
+ export const REDACTION_TOKEN_TYPES = Object.freeze([
43
+ 'PERSON', 'ORG', 'LOCATION', 'ADDRESS', 'EMAIL', 'PHONE', 'ID', 'SSN', 'IBAN',
44
+ 'CREDITCARD', 'CARD', 'POST', 'FAC', 'GROUP', 'NRP', 'ENTITY', 'KEY', 'SECRET',
45
+ 'TERM', 'PII', 'OTHER',
46
+ ]);
47
+
48
+ /**
49
+ * Is this bare string one of OUR placeholders?
50
+ *
51
+ * Matched against the known type vocabulary rather than the bare `[A-Z]+_\d+` shape, and
52
+ * that holds even inside brackets: `[[Q3_2026]]` and `[[PHASE_2]]` are links people
53
+ * genuinely write, so a shape test would trade one invisible bug for another. A custom
54
+ * dictionary type is the accepted gap — it is user-chosen, so a downstream consumer filing
55
+ * it is a name the user picked, not a stranger's identity.
56
+ *
57
+ * Bracket-tolerant: callers ask both before and after a wikilink parser has stripped them.
58
+ */
59
+ export function isRedactionToken(value) {
60
+ const bare = String(value ?? '').trim().replace(/^\[{1,2}|\]{1,2}$/g, '');
61
+ const m = /^([A-Z][A-Z0-9]*)_\d+$/.exec(bare);
62
+ return !!m && REDACTION_TOKEN_TYPES.includes(m[1]);
63
+ }
64
+
25
65
  // Bracket-TOLERANT match of the same token. Smaller models routinely drop or mangle
26
66
  // the [[ ]] when echoing a placeholder into tool-call JSON — e.g. they emit "ORG_1"
27
67
  // or "[ORG_1]" instead of "[[ORG_1]]" — which the strict TOKEN_RE misses, leaving
@@ -339,6 +379,37 @@ export function restoreWithAliases(text, vault) {
339
379
  return out;
340
380
  }
341
381
 
382
+ /**
383
+ * THE LAST LINE BEFORE A HUMAN READS IT.
384
+ *
385
+ * `restoreText` undoes what a given vault minted. This asks the harder question a UI has to
386
+ * answer: is there ANY placeholder left in what I am about to show? A reply is redacted for
387
+ * the model's benefit, never the reader's — so a token reaching the screen is always a bug,
388
+ * and one that is invisible to the code that caused it, because by then the turn is over.
389
+ *
390
+ * It exists because a turn can mint tokens in one vault and be restored against another (or
391
+ * against none): a local agent under "redact for remote only" gets no vault at all, while
392
+ * tool results reaching it may already carry placeholders from somewhere else. Every one of
393
+ * those paths ends at the same render call, so the check belongs there.
394
+ *
395
+ * Returns `{ text, unresolved }` — restored where the vault knows the token, and the list of
396
+ * the ones it could not, so the caller can decide (mask, warn, log) rather than silently
397
+ * shipping `[[PERSON_5]]` to a person reading about their own colleagues.
398
+ */
399
+ export function scrubPlaceholders(text, vault) {
400
+ const src = String(text ?? '');
401
+ if (!src) return { text: src, unresolved: [] };
402
+ const unresolved = [];
403
+ const out = src.replace(TOKEN_RE, (match) => {
404
+ const value = vault?.byToken?.get(match);
405
+ if (value != null) return value;
406
+ unresolved.push(match);
407
+ return match;
408
+ });
409
+ TOKEN_RE.lastIndex = 0;
410
+ return { text: out, unresolved };
411
+ }
412
+
342
413
  // True if the text still contains any redaction placeholder (useful for streaming
343
414
  // restore — buffer a tail when a token may be split across chunks).
344
415
  export function hasToken(text) {