@chatpanel/pii 0.2.15 → 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.2.15",
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
@@ -77,6 +77,16 @@ export function placeholderToolNote({ toolData = 'real' } = {}) {
77
77
  return intro + remote + rules;
78
78
  }
79
79
 
80
+ // Tools whose results come from the PUBLIC web rather than from the user's own machine or
81
+ // accounts. Deliberately a short, explicit allowlist rather than a heuristic: being wrong in
82
+ // the "public" direction would send real PII to a model, so a tool earns its place here only
83
+ // when its output is public by construction. Page tools are NOT here — the user's open tab
84
+ // may be an internal app.
85
+ const PUBLIC_SOURCE_TOOLS = new Set(['web_search', 'web_fetch', 'fetch_url']);
86
+ export function isPublicSourceTool(name) {
87
+ return PUBLIC_SOURCE_TOOLS.has(String(name || '').toLowerCase());
88
+ }
89
+
80
90
  export function makeToolHarness({ vault = null, toolData = 'real', redactOpts = null, redactResults = true, remoteTools = null } = {}) {
81
91
  const on = !!vault; // privacy enabled for this turn?
82
92
  const redactRemote = toolData === 'redactRemote';
@@ -109,6 +119,20 @@ export function makeToolHarness({ vault = null, toolData = 'real', redactOpts =
109
119
  // via a nested field the old string/{text}-only path skipped.
110
120
  toModelResult(name, raw) {
111
121
  if (!on || !redactResults || !redactOpts) return raw;
122
+ // PUBLIC RESULTS ARE NOT THE USER'S DATA.
123
+ //
124
+ // Redaction exists to stop the user's information LEAVING the device. Text coming back
125
+ // from a public web search never left it — the model's provider could fetch the same
126
+ // page itself — so rewriting it buys no privacy and actively corrupts facts: a
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
130
+ // (emails, phones, keys) also fire on unrelated strangers' details in fetched pages.
131
+ //
132
+ // So public-source results pass through intact. Everything local or private — history,
133
+ // meetings, notes, the user's own page, any MCP server — is redacted exactly as before,
134
+ // which is where a leak could actually happen.
135
+ if (isPublicSourceTool(name)) return raw;
112
136
  return redactResultShape(raw, vault, redactOpts);
113
137
  },
114
138