@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/pii",
3
- "version": "0.3.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 res = await fetchImpl(url, {
129
- method: 'POST',
130
- headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
131
- body: JSON.stringify({
132
- model: det.model || 'local',
133
- temperature: 0,
134
- max_tokens: det.maxTokens || 256,
135
- messages: [{ role: 'system', content: EXTRACT_SYS }, { role: 'user', content: text }],
136
- }),
137
- signal,
138
- });
139
- if (!res.ok) throw new Error(`detect HTTP ${res.status}`);
140
- const json = await res.json();
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
- return normalizeEntities(parseJsonLoose(content), det.types);
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
- export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis.fetch, strict = false } = {}) {
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
- ents = await withTimeout(run(capped, det, signal, fetchImpl), det.timeoutMs || 1500, signal);
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. "Alex") back to the real value (e.g. "Suresh")
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 (SureshJohn) renamed a public actor inside search results,
128
- // and the answer came back about "Mysore Seshaiah John Babu Naidu". The detectors
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,