@chatpanel/gateway 0.6.68 → 0.6.69

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.68",
3
+ "version": "0.6.69",
4
4
  "description": "Local privacy gateway \u2014 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": {
package/src/server.js CHANGED
@@ -55,7 +55,7 @@ import * as openai from './openai.js';
55
55
  import * as responses from './responses.js';
56
56
  import * as anthropic from './anthropic.js';
57
57
 
58
- export const VERSION = '0.6.68';
58
+ export const VERSION = '0.6.69';
59
59
 
60
60
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
61
61
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -170,7 +170,7 @@ function mkTrace(sink) {
170
170
  const entry = /** @type {any} */ ({ ...this.meta, timings });
171
171
  setImmediate(() => {
172
172
  sink(entry);
173
- 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)}`);
173
+ console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · ${entry.redaction === 'off' ? 'redaction OFF (client request)' : `redacted ${entry.redacted || 0}`}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
174
174
  });
175
175
  },
176
176
  };
@@ -1624,6 +1624,7 @@ export function createGateway(cfg = loadConfig()) {
1624
1624
  let redactedCount = 0;
1625
1625
  let sanitizedCount = 0;
1626
1626
  let narrowedTools = 0;
1627
+ let redactionOff = false;
1627
1628
  let isPro = true;
1628
1629
  // Off the hot path: only build a trace when logging is on, so it adds nothing
1629
1630
  // when off (no clock reads, no record, no console line).
@@ -1659,14 +1660,24 @@ export function createGateway(cfg = loadConfig()) {
1659
1660
  type: 'free_limit_reached',
1660
1661
  } });
1661
1662
  }
1662
- const segs = r.adapter.collectSegments(body, cfg.redaction);
1663
+ // REDACTION OFF, FOR THIS REQUEST, BECAUSE THE USER SAID SO. A note task that reads
1664
+ // "write about NVIDIA GPUs" had NVIDIA replaced with an organisation placeholder, and
1665
+ // the model — seeing only [[ORG_1]] — guessed a different company and wrote about
1666
+ // that. The policy is right for the corpus and wrong for the user's own instruction,
1667
+ // and only the user can tell which a given turn is. So an AUTHENTICATED local client
1668
+ // (the desktop, the extension — both hold the gateway token) may send
1669
+ // `X-ChatPanel-Redaction: off`; the trace records it so the ledger says "redaction
1670
+ // was off for this turn" rather than "0 redactions", which would read as "nothing to
1671
+ // redact". Anonymous callers cannot switch it off: that is what the token is for.
1672
+ redactionOff = String(req.headers['x-chatpanel-redaction'] || '').trim().toLowerCase() === 'off' && isAdminAuthorized(req);
1673
+ const segs = redactionOff ? [] : r.adapter.collectSegments(body, cfg.redaction);
1663
1674
  const ac = new AbortController();
1664
1675
  req.on('close', () => ac.abort());
1665
1676
  const rd0 = trace ? trace.clock() : 0;
1666
1677
  // Redact at the configured tier for everyone (free users get name/org
1667
1678
  // redaction within their allowance); the custom dictionary stays capped for
1668
1679
  // free (isPro decides that inside).
1669
- const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, {
1680
+ const { vault: v, count, sanitized } = redactionOff ? { vault: null, count: 0, sanitized: 0 } : await redactSegments(segs, cfg.redaction, {
1670
1681
  signal: ac.signal,
1671
1682
  isPro,
1672
1683
  // A detector is the only hop that sees the request BEFORE redaction. It is guarded
@@ -1695,7 +1706,7 @@ export function createGateway(cfg = loadConfig()) {
1695
1706
  // tools (so privacy-aware models USE them instead of refusing). Injected
1696
1707
  // AFTER redaction so the note isn't itself redacted. Covers BOTH the API
1697
1708
  // forward and the relay (which reads system from this same body).
1698
- if (Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
1709
+ if (!redactionOff && Array.isArray(body.tools) && body.tools.length && typeof r.adapter.injectSystemNote === 'function') {
1699
1710
  r.adapter.injectSystemNote(body, placeholderToolNote({ toolData: cfg.tools?.toolData }));
1700
1711
  }
1701
1712
  outBody = Buffer.from(JSON.stringify(body), 'utf8');
@@ -1749,7 +1760,7 @@ export function createGateway(cfg = loadConfig()) {
1749
1760
  });
1750
1761
  }
1751
1762
  if (trace) {
1752
- 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) };
1763
+ 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), redaction: redactionOff ? 'off' : (cfg.redaction.tier || 'basic') };
1753
1764
  }
1754
1765
  if (dest && dest.type === 'api') {
1755
1766
  if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
package/src/stream.js CHANGED
@@ -17,23 +17,30 @@ import { restoreText, restoreWithAliases } from '@chatpanel/pii';
17
17
  // Returns a TransformStream-free chunk transformer: feed it decoded string chunks,
18
18
  // it returns the prefix that's safe to forward now and buffers a possibly-partial
19
19
  // trailing token. Call flush() at end-of-stream.
20
+ /**
21
+ * Split `buf` into what is safe to forward now and what may still be the start of a token.
22
+ *
23
+ * Two shapes are held back: an unterminated "[[" (a token mid-way), and a trailing SINGLE
24
+ * "[" — a model tokenizes "[[ORG_1]]" as "[" + "[ORG_1]]" often enough that forwarding the
25
+ * lone bracket left every restored name wearing one: "[NVIDIA designs GPUs". "[[" cannot
26
+ * legitimately appear except as a token open, and a lone "[" at a chunk edge costs nothing to
27
+ * wait one chunk for.
28
+ */
29
+ function splitSafe(buf) {
30
+ const open = buf.lastIndexOf('[[');
31
+ if (open !== -1 && !buf.slice(open).includes(']]')) return [buf.slice(0, open), buf.slice(open)];
32
+ if (buf.endsWith('[')) return [buf.slice(0, -1), '['];
33
+ return [buf, ''];
34
+ }
35
+
20
36
  export function makeTokenRestorer(vault) {
21
37
  let buf = '';
22
38
  return {
23
39
  push(chunk) {
24
40
  if (!vault) return chunk || '';
25
41
  buf += chunk || '';
26
- // If an unterminated "[[" sits in the tail, a token may still be forming —
27
- // hold from there. "[[" can't legitimately appear except as a token open.
28
- const open = buf.lastIndexOf('[[');
29
42
  let safe;
30
- if (open !== -1 && !buf.slice(open).includes(']]')) {
31
- safe = buf.slice(0, open);
32
- buf = buf.slice(open);
33
- } else {
34
- safe = buf;
35
- buf = '';
36
- }
43
+ [safe, buf] = splitSafe(buf);
37
44
  return restoreText(safe, vault);
38
45
  },
39
46
  flush() {
@@ -83,10 +90,8 @@ function makeFieldRestorer(vault, restoreFn) {
83
90
  return {
84
91
  push(chunk) {
85
92
  buf += chunk || '';
86
- const open = buf.lastIndexOf('[[');
87
93
  let safe;
88
- if (open !== -1 && !buf.slice(open).includes(']]')) { safe = buf.slice(0, open); buf = buf.slice(open); }
89
- else { safe = buf; buf = ''; }
94
+ [safe, buf] = splitSafe(buf);
90
95
  return restoreFn(safe, vault);
91
96
  },
92
97
  flush() { const out = restoreFn(buf, vault); buf = ''; return out; },