@chatpanel/pii 0.3.0 → 0.7.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/package.json +1 -1
- package/pii-detect.js +125 -23
- package/pii-redact.js +72 -1
- package/tool-harness.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/pii",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -101,10 +145,19 @@ export function parseJsonLoose(s) {
|
|
|
101
145
|
try { return JSON.parse(String(s).slice(a, b + 1)); } catch { return null; }
|
|
102
146
|
}
|
|
103
147
|
|
|
148
|
+
// The instruction WITHOUT the shape. The shape now comes from ENTITIES_SCHEMA in
|
|
149
|
+
// `@chatpanel/events` — the one object that renders the prompt block, builds the
|
|
150
|
+
// `response_format` a server enforces, and reads the reply. INJECTED, not imported: this
|
|
151
|
+
// package ships zero dependencies so the bridge can vendor it. A host without it still works.
|
|
104
152
|
export const EXTRACT_SYS = 'You extract sensitive entities from text for redaction. '
|
|
105
|
-
+ 'Return ONLY JSON: {"entities":[{"value":"<verbatim text>","type":"PERSON|ORG|LOCATION|ID|EMAIL|PHONE|OTHER"}]}. '
|
|
106
153
|
+ 'Copy each value exactly as it appears. Include people, organizations, locations, and account/ID numbers. No commentary, no code fences.';
|
|
107
154
|
|
|
155
|
+
const FALLBACK_SHAPE = 'Return ONLY JSON: {"entities":[{"value":"<verbatim text>",'
|
|
156
|
+
+ '"type":"PERSON|ORG|LOCATION|ID|EMAIL|PHONE|OTHER"}]}. No commentary, no code fences.';
|
|
157
|
+
|
|
158
|
+
// The seam: { block, format(mode), parse(text) }. Absent, everything below behaves as before.
|
|
159
|
+
const NO_STRUCTURE = Object.freeze({ block: '', format: null, parse: null });
|
|
160
|
+
|
|
108
161
|
async function detectViaEndpoint(text, det, signal, fetchImpl) {
|
|
109
162
|
const res = await fetchImpl(det.url, {
|
|
110
163
|
method: 'POST',
|
|
@@ -116,7 +169,7 @@ async function detectViaEndpoint(text, det, signal, fetchImpl) {
|
|
|
116
169
|
return normalizeEntities(await res.json(), det.types);
|
|
117
170
|
}
|
|
118
171
|
|
|
119
|
-
async function detectViaOpenAI(text, det, signal, fetchImpl) {
|
|
172
|
+
async function detectViaOpenAI(text, det, signal, fetchImpl, structured = NO_STRUCTURE) {
|
|
120
173
|
const base = String(det.url || '').replace(/\/$/, '');
|
|
121
174
|
// Build the chat URL the SAME way the chat path does. An OpenAI-compatible baseUrl
|
|
122
175
|
// already ends in /v1 (Ollama, OpenRouter, NVIDIA, OpenAI…) → only add
|
|
@@ -125,25 +178,70 @@ async function detectViaOpenAI(text, det, signal, fetchImpl) {
|
|
|
125
178
|
const url = /\/chat\/completions$/.test(base) ? base
|
|
126
179
|
: /\/v\d+$/.test(base) ? `${base}/chat/completions`
|
|
127
180
|
: `${base}/v1/chat/completions`;
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
181
|
+
const sys = `${EXTRACT_SYS}\n\n${structured.block || FALLBACK_SHAPE}`;
|
|
182
|
+
const ask = async (mode) => {
|
|
183
|
+
const fmt = structured.format ? structured.format(mode) : null;
|
|
184
|
+
const res = await fetchImpl(url, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
model: det.model || 'local',
|
|
189
|
+
temperature: 0,
|
|
190
|
+
max_tokens: det.maxTokens || 256,
|
|
191
|
+
messages: [{ role: 'system', content: sys }, { role: 'user', content: text }],
|
|
192
|
+
...(fmt || {}),
|
|
193
|
+
}),
|
|
194
|
+
signal,
|
|
195
|
+
});
|
|
196
|
+
// 400/422 is the server saying it does not understand the body — a different thing from
|
|
197
|
+
// being down, and the only one worth retrying with a weaker one.
|
|
198
|
+
if (!res.ok) { const e = new Error(`detect HTTP ${res.status}`); e.status = res.status; throw e; }
|
|
199
|
+
return res.json();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Grammar first, then plain JSON mode, then nothing. `json_schema` constrains the decoder to
|
|
203
|
+
// this exact shape — the difference between a 3B local model that answers and one that
|
|
204
|
+
// writes a paragraph — but many servers reject the field, so each rung is tried once.
|
|
205
|
+
let json = null;
|
|
206
|
+
if (structured.format) {
|
|
207
|
+
for (const mode of ['schema', 'object', 'none']) {
|
|
208
|
+
try { json = await ask(mode); break; }
|
|
209
|
+
catch (e) { if (mode === 'none' || (e.status !== 400 && e.status !== 422)) throw e; }
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
json = await ask('none');
|
|
213
|
+
}
|
|
141
214
|
const content = json?.choices?.[0]?.message?.content ?? json?.content ?? '';
|
|
142
|
-
|
|
215
|
+
// The schema-aligned reader when the host has one; the loose slice otherwise.
|
|
216
|
+
const parsed = structured.parse ? structured.parse(content) : parseJsonLoose(content);
|
|
217
|
+
return normalizeEntities(parsed, det.types);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Never throws: an egress record that could break redaction is worse than no record.
|
|
221
|
+
function report(onEgress, det, sent, t0, count, err) {
|
|
222
|
+
if (typeof onEgress !== 'function') return;
|
|
223
|
+
try {
|
|
224
|
+
onEgress({
|
|
225
|
+
backend: det.backend || '',
|
|
226
|
+
host: hostOf(det.url),
|
|
227
|
+
chars: sent.length,
|
|
228
|
+
entities: count,
|
|
229
|
+
ms: Date.now() - t0,
|
|
230
|
+
ok: !err,
|
|
231
|
+
error: err ? String(err.message || err).slice(0, 200) : '',
|
|
232
|
+
});
|
|
233
|
+
} catch { /* observability must never be the reason detection fails */ }
|
|
143
234
|
}
|
|
144
235
|
|
|
145
236
|
// Returns [{value, type}] spans for `text`, or [] (fail-open) on any error/timeout.
|
|
146
|
-
|
|
237
|
+
// `onEgress` reports that RAW text left for a detector — the FACT, never the text. This is
|
|
238
|
+
// the one call that sends un-redacted content off the device (you cannot redact before you
|
|
239
|
+
// have detected); it is SSRF-guarded but was logged nowhere, and det.url accepts any public
|
|
240
|
+
// host. Injected, like `structured`: this package has no logger. The record carries the HOST
|
|
241
|
+
// (never the full URL, which can hold a token) and counts — never values.
|
|
242
|
+
const hostOf = (u) => { try { return new URL(String(u)).host; } catch { return ''; } };
|
|
243
|
+
|
|
244
|
+
export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis.fetch, strict = false, structured = NO_STRUCTURE, onEgress = null } = {}) {
|
|
147
245
|
const det = cfg?.detection;
|
|
148
246
|
if (!det || !det.backend || det.backend === 'off' || !det.url || typeof fetchImpl !== 'function') return [];
|
|
149
247
|
const capped = String(text || '').slice(0, det.maxChars || 8000);
|
|
@@ -158,7 +256,11 @@ export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis
|
|
|
158
256
|
// is the normal case. A blocked URL fails open (deterministic-only), or surfaces
|
|
159
257
|
// to the Test button in strict mode.
|
|
160
258
|
assertEndpointUrl(det.url);
|
|
161
|
-
|
|
259
|
+
const t0 = Date.now();
|
|
260
|
+
try {
|
|
261
|
+
ents = await withTimeout(run(capped, det, signal, fetchImpl, structured), det.timeoutMs || 1500, signal);
|
|
262
|
+
report(onEgress, det, capped, t0, ents.length, null);
|
|
263
|
+
} catch (e) { report(onEgress, det, capped, t0, 0, e); throw e; }
|
|
162
264
|
} catch (e) {
|
|
163
265
|
if (strict) throw e; // surface errors to the Test button
|
|
164
266
|
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
|
|
@@ -33,7 +73,7 @@ const TOLERANT_TOKEN_RE = /\[{0,2}([A-Z][A-Z0-9]*_\d+)\]{0,2}/g;
|
|
|
33
73
|
// A vault is the per-conversation mapping between placeholders and originals. Keep
|
|
34
74
|
// one per conversation so PERSON_1 means the same entity across turns.
|
|
35
75
|
export function createVault() {
|
|
36
|
-
// `aliases` maps a pseudonym (e.g. "
|
|
76
|
+
// `aliases` maps a pseudonym (e.g. "Robin") back to the real value (e.g. "Alex Rivera")
|
|
37
77
|
// so LOCAL tool calls (history/meeting search) can run on real data. The reply
|
|
38
78
|
// restorer ignores it — pseudonyms stay permanent in the user's view.
|
|
39
79
|
return { byToken: new Map(), byValue: new Map(), counts: new Map(), aliases: new Map() };
|
|
@@ -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) {
|
package/tool-harness.js
CHANGED
|
@@ -124,8 +124,9 @@ export function makeToolHarness({ vault = null, toolData = 'real', redactOpts =
|
|
|
124
124
|
// Redaction exists to stop the user's information LEAVING the device. Text coming back
|
|
125
125
|
// from a public web search never left it — the model's provider could fetch the same
|
|
126
126
|
// page itself — so rewriting it buys no privacy and actively corrupts facts: a
|
|
127
|
-
// dictionary pseudonym (
|
|
128
|
-
// and the answer came back about
|
|
127
|
+
// dictionary pseudonym (a user's own name → a stand-in) renamed a same-named public
|
|
128
|
+
// figure inside search results, and the answer came back about a person who does not
|
|
129
|
+
// exist. The detectors
|
|
129
130
|
// (emails, phones, keys) also fire on unrelated strangers' details in fetched pages.
|
|
130
131
|
//
|
|
131
132
|
// So public-source results pass through intact. Everything local or private — history,
|