@chatpanel/gateway 0.6.6 → 0.6.9
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 +2 -2
- package/src/log.js +21 -0
- package/src/redact.js +18 -3
- package/src/server.js +8 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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.
|
|
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/log.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Timestamped console. Gateway logs (NER lifecycle, per-request redaction lines,
|
|
2
|
+
// entitlement refresh) are useless for debugging without a clock — prefix every
|
|
3
|
+
// console line with a local YYYY-MM-DD HH:MM:SS stamp. Call once at startup,
|
|
4
|
+
// before anything logs. Idempotent (won't double-wrap if called twice).
|
|
5
|
+
|
|
6
|
+
let installed = false;
|
|
7
|
+
|
|
8
|
+
function stamp(d = new Date()) {
|
|
9
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
10
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
11
|
+
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function installTimestampedConsole() {
|
|
15
|
+
if (installed) return;
|
|
16
|
+
installed = true;
|
|
17
|
+
for (const method of ['log', 'info', 'warn', 'error']) {
|
|
18
|
+
const orig = console[method].bind(console);
|
|
19
|
+
console[method] = (...args) => orig(`[${stamp()}]`, ...args);
|
|
20
|
+
}
|
|
21
|
+
}
|
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
|
@@ -27,6 +27,7 @@ import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
|
27
27
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
28
28
|
import { shaperFor } from './shape.js';
|
|
29
29
|
import { startNer } from './ner.js';
|
|
30
|
+
import { installTimestampedConsole } from './log.js';
|
|
30
31
|
import * as nerEngine from './ner-engine.js';
|
|
31
32
|
import { MODEL_CATALOG, isKnownModel } from './models.js';
|
|
32
33
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
@@ -36,7 +37,7 @@ import * as openai from './openai.js';
|
|
|
36
37
|
import * as responses from './responses.js';
|
|
37
38
|
import * as anthropic from './anthropic.js';
|
|
38
39
|
|
|
39
|
-
export const VERSION = '0.6.
|
|
40
|
+
export const VERSION = '0.6.9';
|
|
40
41
|
|
|
41
42
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
42
43
|
|
|
@@ -137,7 +138,7 @@ function mkTrace(sink) {
|
|
|
137
138
|
const entry = /** @type {any} */ ({ ...this.meta, timings });
|
|
138
139
|
setImmediate(() => {
|
|
139
140
|
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)}`);
|
|
141
|
+
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
142
|
});
|
|
142
143
|
},
|
|
143
144
|
};
|
|
@@ -613,6 +614,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
613
614
|
let body = null;
|
|
614
615
|
let outBody = raw;
|
|
615
616
|
let redactedCount = 0;
|
|
617
|
+
let sanitizedCount = 0;
|
|
616
618
|
let narrowedTools = 0;
|
|
617
619
|
let isPro = true;
|
|
618
620
|
// Off the hot path: only build a trace when logging is on, so it adds nothing
|
|
@@ -656,10 +658,11 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
656
658
|
// Redact at the configured tier for everyone (free users get genuine
|
|
657
659
|
// name/org redaction within their allowance, not a downgraded preview);
|
|
658
660
|
// 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 });
|
|
661
|
+
const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
660
662
|
if (trace) trace.lap('redact', rd0);
|
|
661
663
|
vault = v;
|
|
662
664
|
redactedCount = count;
|
|
665
|
+
sanitizedCount = sanitized || 0;
|
|
663
666
|
// Burn one lifetime free credit only when we actually redacted something,
|
|
664
667
|
// then persist so the count survives a restart. (No-op / no write for Pro.)
|
|
665
668
|
if (!isPro && count > 0) {
|
|
@@ -687,7 +690,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
687
690
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
688
691
|
const dest = resolveDestination(body?.model, cfg, r.kind);
|
|
689
692
|
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) };
|
|
693
|
+
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
694
|
}
|
|
692
695
|
if (dest && dest.type === 'api') {
|
|
693
696
|
if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
|
|
@@ -702,6 +705,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
702
705
|
}
|
|
703
706
|
|
|
704
707
|
export function start(cfg = loadConfig()) {
|
|
708
|
+
installTimestampedConsole(); // every gateway log line gets a clock — before anything logs
|
|
705
709
|
const server = createGateway(cfg);
|
|
706
710
|
const ner = startNer(cfg); // may mutate cfg.redaction when it comes up
|
|
707
711
|
// Re-validate the stored Pro entitlement online on an interval, so a refunded /
|