@chatpanel/pii 0.3.0 → 0.4.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 +76 -18
- package/pii-redact.js +1 -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.4.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
|
@@ -101,10 +101,19 @@ export function parseJsonLoose(s) {
|
|
|
101
101
|
try { return JSON.parse(String(s).slice(a, b + 1)); } catch { return null; }
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// The instruction WITHOUT the shape. The shape now comes from ENTITIES_SCHEMA in
|
|
105
|
+
// `@chatpanel/events` — the one object that renders the prompt block, builds the
|
|
106
|
+
// `response_format` a server enforces, and reads the reply. INJECTED, not imported: this
|
|
107
|
+
// package ships zero dependencies so the bridge can vendor it. A host without it still works.
|
|
104
108
|
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
109
|
+ 'Copy each value exactly as it appears. Include people, organizations, locations, and account/ID numbers. No commentary, no code fences.';
|
|
107
110
|
|
|
111
|
+
const FALLBACK_SHAPE = 'Return ONLY JSON: {"entities":[{"value":"<verbatim text>",'
|
|
112
|
+
+ '"type":"PERSON|ORG|LOCATION|ID|EMAIL|PHONE|OTHER"}]}. No commentary, no code fences.';
|
|
113
|
+
|
|
114
|
+
// The seam: { block, format(mode), parse(text) }. Absent, everything below behaves as before.
|
|
115
|
+
const NO_STRUCTURE = Object.freeze({ block: '', format: null, parse: null });
|
|
116
|
+
|
|
108
117
|
async function detectViaEndpoint(text, det, signal, fetchImpl) {
|
|
109
118
|
const res = await fetchImpl(det.url, {
|
|
110
119
|
method: 'POST',
|
|
@@ -116,7 +125,7 @@ async function detectViaEndpoint(text, det, signal, fetchImpl) {
|
|
|
116
125
|
return normalizeEntities(await res.json(), det.types);
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
async function detectViaOpenAI(text, det, signal, fetchImpl) {
|
|
128
|
+
async function detectViaOpenAI(text, det, signal, fetchImpl, structured = NO_STRUCTURE) {
|
|
120
129
|
const base = String(det.url || '').replace(/\/$/, '');
|
|
121
130
|
// Build the chat URL the SAME way the chat path does. An OpenAI-compatible baseUrl
|
|
122
131
|
// already ends in /v1 (Ollama, OpenRouter, NVIDIA, OpenAI…) → only add
|
|
@@ -125,25 +134,70 @@ async function detectViaOpenAI(text, det, signal, fetchImpl) {
|
|
|
125
134
|
const url = /\/chat\/completions$/.test(base) ? base
|
|
126
135
|
: /\/v\d+$/.test(base) ? `${base}/chat/completions`
|
|
127
136
|
: `${base}/v1/chat/completions`;
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
const sys = `${EXTRACT_SYS}\n\n${structured.block || FALLBACK_SHAPE}`;
|
|
138
|
+
const ask = async (mode) => {
|
|
139
|
+
const fmt = structured.format ? structured.format(mode) : null;
|
|
140
|
+
const res = await fetchImpl(url, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
model: det.model || 'local',
|
|
145
|
+
temperature: 0,
|
|
146
|
+
max_tokens: det.maxTokens || 256,
|
|
147
|
+
messages: [{ role: 'system', content: sys }, { role: 'user', content: text }],
|
|
148
|
+
...(fmt || {}),
|
|
149
|
+
}),
|
|
150
|
+
signal,
|
|
151
|
+
});
|
|
152
|
+
// 400/422 is the server saying it does not understand the body — a different thing from
|
|
153
|
+
// being down, and the only one worth retrying with a weaker one.
|
|
154
|
+
if (!res.ok) { const e = new Error(`detect HTTP ${res.status}`); e.status = res.status; throw e; }
|
|
155
|
+
return res.json();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Grammar first, then plain JSON mode, then nothing. `json_schema` constrains the decoder to
|
|
159
|
+
// this exact shape — the difference between a 3B local model that answers and one that
|
|
160
|
+
// writes a paragraph — but many servers reject the field, so each rung is tried once.
|
|
161
|
+
let json = null;
|
|
162
|
+
if (structured.format) {
|
|
163
|
+
for (const mode of ['schema', 'object', 'none']) {
|
|
164
|
+
try { json = await ask(mode); break; }
|
|
165
|
+
catch (e) { if (mode === 'none' || (e.status !== 400 && e.status !== 422)) throw e; }
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
json = await ask('none');
|
|
169
|
+
}
|
|
141
170
|
const content = json?.choices?.[0]?.message?.content ?? json?.content ?? '';
|
|
142
|
-
|
|
171
|
+
// The schema-aligned reader when the host has one; the loose slice otherwise.
|
|
172
|
+
const parsed = structured.parse ? structured.parse(content) : parseJsonLoose(content);
|
|
173
|
+
return normalizeEntities(parsed, det.types);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Never throws: an egress record that could break redaction is worse than no record.
|
|
177
|
+
function report(onEgress, det, sent, t0, count, err) {
|
|
178
|
+
if (typeof onEgress !== 'function') return;
|
|
179
|
+
try {
|
|
180
|
+
onEgress({
|
|
181
|
+
backend: det.backend || '',
|
|
182
|
+
host: hostOf(det.url),
|
|
183
|
+
chars: sent.length,
|
|
184
|
+
entities: count,
|
|
185
|
+
ms: Date.now() - t0,
|
|
186
|
+
ok: !err,
|
|
187
|
+
error: err ? String(err.message || err).slice(0, 200) : '',
|
|
188
|
+
});
|
|
189
|
+
} catch { /* observability must never be the reason detection fails */ }
|
|
143
190
|
}
|
|
144
191
|
|
|
145
192
|
// Returns [{value, type}] spans for `text`, or [] (fail-open) on any error/timeout.
|
|
146
|
-
|
|
193
|
+
// `onEgress` reports that RAW text left for a detector — the FACT, never the text. This is
|
|
194
|
+
// the one call that sends un-redacted content off the device (you cannot redact before you
|
|
195
|
+
// have detected); it is SSRF-guarded but was logged nowhere, and det.url accepts any public
|
|
196
|
+
// host. Injected, like `structured`: this package has no logger. The record carries the HOST
|
|
197
|
+
// (never the full URL, which can hold a token) and counts — never values.
|
|
198
|
+
const hostOf = (u) => { try { return new URL(String(u)).host; } catch { return ''; } };
|
|
199
|
+
|
|
200
|
+
export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis.fetch, strict = false, structured = NO_STRUCTURE, onEgress = null } = {}) {
|
|
147
201
|
const det = cfg?.detection;
|
|
148
202
|
if (!det || !det.backend || det.backend === 'off' || !det.url || typeof fetchImpl !== 'function') return [];
|
|
149
203
|
const capped = String(text || '').slice(0, det.maxChars || 8000);
|
|
@@ -158,7 +212,11 @@ export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis
|
|
|
158
212
|
// is the normal case. A blocked URL fails open (deterministic-only), or surfaces
|
|
159
213
|
// to the Test button in strict mode.
|
|
160
214
|
assertEndpointUrl(det.url);
|
|
161
|
-
|
|
215
|
+
const t0 = Date.now();
|
|
216
|
+
try {
|
|
217
|
+
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; }
|
|
162
220
|
} catch (e) {
|
|
163
221
|
if (strict) throw e; // surface errors to the Test button
|
|
164
222
|
ents = []; // otherwise fail open — deterministic redaction still applies
|
package/pii-redact.js
CHANGED
|
@@ -33,7 +33,7 @@ const TOLERANT_TOKEN_RE = /\[{0,2}([A-Z][A-Z0-9]*_\d+)\]{0,2}/g;
|
|
|
33
33
|
// A vault is the per-conversation mapping between placeholders and originals. Keep
|
|
34
34
|
// one per conversation so PERSON_1 means the same entity across turns.
|
|
35
35
|
export function createVault() {
|
|
36
|
-
// `aliases` maps a pseudonym (e.g. "
|
|
36
|
+
// `aliases` maps a pseudonym (e.g. "Robin") back to the real value (e.g. "Alex Rivera")
|
|
37
37
|
// so LOCAL tool calls (history/meeting search) can run on real data. The reply
|
|
38
38
|
// restorer ignores it — pseudonyms stay permanent in the user's view.
|
|
39
39
|
return { byToken: new Map(), byValue: new Map(), counts: new Map(), aliases: new Map() };
|
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,
|