@chatpanel/gateway 0.6.6 → 0.6.8

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/gateway",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=18"
28
28
  },
29
29
  "dependencies": {
30
- "@chatpanel/pii": "^0.2.9",
30
+ "@chatpanel/pii": "^0.2.10",
31
31
  "@huggingface/transformers": "^4.2.0",
32
32
  "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
33
33
  },
package/src/redact.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // mapping is self-consistent within the request. (Same reasoning as the
9
9
  // extension's pii-pipeline.)
10
10
 
11
- import { createVault, redactText, detectEntities, gatedDictionary } from '@chatpanel/pii';
11
+ import { createVault, redactText, detectEntities, gatedDictionary, sanitizeUnicode } from '@chatpanel/pii';
12
12
  import * as engine from './ner-engine.js';
13
13
 
14
14
  // tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
@@ -20,8 +20,23 @@ import * as engine from './ner-engine.js';
20
20
  // still a Pro power feature: gatedDictionary caps it to FREE_DICT_LIMIT for free.
21
21
  export async function redactSegments(segments, redactionCfg, { signal, isPro = true } = {}) {
22
22
  const vault = createVault();
23
+
24
+ // De-steganography FIRST (before detection). Invisible/format Unicode is a triple
25
+ // threat at this boundary: it can split a value so the detector misses it and the
26
+ // model reassembles real PII (redaction bypass), smuggle a hidden instruction via
27
+ // Tag chars (ASCII smuggling), or carry a fingerprint/watermark a client injected.
28
+ // We strip it in place so detection sees clean text and the forwarded request is
29
+ // clean too. Counted (not silently dropped) so the server can report it.
30
+ let sanitized = 0;
31
+ for (const seg of segments) {
32
+ const before = seg.get();
33
+ if (typeof before !== 'string' || !before) continue;
34
+ const { clean, removed } = sanitizeUnicode(before);
35
+ if (removed) { seg.set(clean); sanitized += removed; }
36
+ }
37
+
23
38
  const texts = segments.map((s) => s.get()).filter((t) => typeof t === 'string' && t);
24
- if (texts.length === 0) return { vault, count: 0 };
39
+ if (texts.length === 0) return { vault, count: 0, sanitized };
25
40
 
26
41
  // Use the configured tier as-is (no free downgrade — the quota is the free gate),
27
42
  // but keep the dictionary capped for free via the shared chatpanel-pii gate.
@@ -64,7 +79,7 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
64
79
  if (after !== before) count++;
65
80
  seg.set(after);
66
81
  }
67
- return { vault, count };
82
+ return { vault, count, sanitized };
68
83
  }
69
84
 
70
85
  // A `segment` is a tiny getter/setter over wherever the text lives in the parsed
package/src/server.js CHANGED
@@ -36,7 +36,7 @@ import * as openai from './openai.js';
36
36
  import * as responses from './responses.js';
37
37
  import * as anthropic from './anthropic.js';
38
38
 
39
- export const VERSION = '0.6.6';
39
+ export const VERSION = '0.6.8';
40
40
 
41
41
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
42
42
 
@@ -137,7 +137,7 @@ function mkTrace(sink) {
137
137
  const entry = /** @type {any} */ ({ ...this.meta, timings });
138
138
  setImmediate(() => {
139
139
  sink(entry);
140
- console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
140
+ console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
141
141
  });
142
142
  },
143
143
  };
@@ -613,6 +613,7 @@ export function createGateway(cfg = loadConfig()) {
613
613
  let body = null;
614
614
  let outBody = raw;
615
615
  let redactedCount = 0;
616
+ let sanitizedCount = 0;
616
617
  let narrowedTools = 0;
617
618
  let isPro = true;
618
619
  // Off the hot path: only build a trace when logging is on, so it adds nothing
@@ -656,10 +657,11 @@ export function createGateway(cfg = loadConfig()) {
656
657
  // Redact at the configured tier for everyone (free users get genuine
657
658
  // name/org redaction within their allowance, not a downgraded preview);
658
659
  // the custom dictionary stays capped for free (isPro decides that inside).
659
- const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
660
+ const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
660
661
  if (trace) trace.lap('redact', rd0);
661
662
  vault = v;
662
663
  redactedCount = count;
664
+ sanitizedCount = sanitized || 0;
663
665
  // Burn one lifetime free credit only when we actually redacted something,
664
666
  // then persist so the count survives a restart. (No-op / no write for Pro.)
665
667
  if (!isPro && count > 0) {
@@ -687,7 +689,7 @@ export function createGateway(cfg = loadConfig()) {
687
689
  // API we forward to). Falls back to the legacy backend when none configured.
688
690
  const dest = resolveDestination(body?.model, cfg, r.kind);
689
691
  if (trace) {
690
- trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
692
+ trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
691
693
  }
692
694
  if (dest && dest.type === 'api') {
693
695
  if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }