@chatpanel/pii 0.2.14 → 0.3.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/pii",
3
- "version": "0.2.14",
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.",
3
+ "version": "0.3.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.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
package/pii-redact.js CHANGED
@@ -345,3 +345,47 @@ export function hasToken(text) {
345
345
  TOKEN_RE.lastIndex = 0;
346
346
  return TOKEN_RE.test(String(text || ''));
347
347
  }
348
+
349
+ // ── What was redacted, for the user's own eyes ──────────────────────────────────────────
350
+ //
351
+ // The privacy promise is invisible unless you can SEE it: which entity types were caught,
352
+ // how many of each, and (on request) the actual before → after pairs. All of that already
353
+ // exists in the vault — this just summarises it, so every client renders the same shape
354
+ // instead of each one re-deriving it.
355
+ //
356
+ // PRIVACY OF THE SUMMARY ITSELF: `types` carries counts only, never values, so it is safe
357
+ // to render, log or persist. Real values live behind `pairs`, which a caller must ask for
358
+ // explicitly (`includeValues: true`) — they are the user's own data, shown on their own
359
+ // device, and must never be written anywhere durable.
360
+
361
+ /** Entity type + count for everything this vault redacted, most-frequent first. */
362
+ export function redactionSummary(vault, { includeValues = false, maxPairs = 200 } = {}) {
363
+ const byType = new Map();
364
+ const pairs = [];
365
+ for (const [token, value] of vault?.byToken || new Map()) {
366
+ const m = /^\[\[([A-Z][A-Z0-9]*)_(\d+)\]\]$/.exec(token);
367
+ const type = m ? m[1] : 'OTHER';
368
+ byType.set(type, (byType.get(type) || 0) + 1);
369
+ if (includeValues && pairs.length < maxPairs) pairs.push({ token, value, type });
370
+ }
371
+ const types = [...byType.entries()]
372
+ .map(([type, count]) => ({ type, count }))
373
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
374
+ return {
375
+ total: types.reduce((n, t) => n + t.count, 0),
376
+ types,
377
+ ...(includeValues ? { pairs } : {}),
378
+ };
379
+ }
380
+
381
+ /** Merge several vault summaries (e.g. every conversation) into one. Counts only. */
382
+ export function mergeRedactionSummaries(summaries) {
383
+ const byType = new Map();
384
+ for (const s of summaries || []) {
385
+ for (const t of s?.types || []) byType.set(t.type, (byType.get(t.type) || 0) + t.count);
386
+ }
387
+ const types = [...byType.entries()]
388
+ .map(([type, count]) => ({ type, count }))
389
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
390
+ return { total: types.reduce((n, t) => n + t.count, 0), types };
391
+ }
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,19 @@ 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 (Suresh → John) renamed a public actor inside search results,
128
+ // and the answer came back about "Mysore Seshaiah John Babu Naidu". The detectors
129
+ // (emails, phones, keys) also fire on unrelated strangers' details in fetched pages.
130
+ //
131
+ // So public-source results pass through intact. Everything local or private — history,
132
+ // meetings, notes, the user's own page, any MCP server — is redacted exactly as before,
133
+ // which is where a leak could actually happen.
134
+ if (isPublicSourceTool(name)) return raw;
112
135
  return redactResultShape(raw, vault, redactOpts);
113
136
  },
114
137