@chatpanel/bridge 0.10.42 → 0.11.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 +6 -2
- package/scripts/sync-channels.mjs +93 -0
- package/scripts/sync-events.mjs +9 -1
- package/scripts/sync-pii.mjs +71 -0
- package/src/channels/adapters/telegram.js +248 -0
- package/src/channels/bridge.js +59 -0
- package/src/channels/eventlog.js +57 -0
- package/src/channels/invoke.js +172 -0
- package/src/channels/normalize.js +58 -0
- package/src/channels/pairing.js +80 -0
- package/src/channels/service.js +270 -0
- package/src/channels/stream.js +86 -0
- package/src/engines/claude.js +41 -1
- package/src/events/capability.js +134 -0
- package/src/events/event.js +183 -0
- package/src/events/reach.js +31 -0
- package/src/events/ref.js +60 -0
- package/src/events/view.js +96 -0
- package/src/pii/index.js +29 -0
- package/src/pii/net.js +111 -0
- package/src/pii/pii-detect.js +177 -0
- package/src/pii/pii-redact.js +399 -0
- package/src/pii/pipeline.js +137 -0
- package/src/pii/sanitize.js +179 -0
- package/src/pii/tool-harness.js +152 -0
- package/src/pii/tool-rank.js +110 -0
- package/src/sanitize.js +6 -136
- package/src/server.js +76 -2
package/src/pii/net.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-pii/net.js (npm @chatpanel/pii).
|
|
3
|
+
// Edit there, then run: npm run sync:pii
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve.
|
|
8
|
+
|
|
9
|
+
// Shared host classifier + outbound-URL guard — the SSRF primitive.
|
|
10
|
+
//
|
|
11
|
+
// One implementation of "what is a loopback / cloud-metadata / private host",
|
|
12
|
+
// delivered the way the rest of @chatpanel/pii is: npm dependency for the
|
|
13
|
+
// gateway/bridge, vendorable into the browser extension (pure — only URL + string
|
|
14
|
+
// ops, no node APIs, so it runs in a Worker/service-worker too). Replaces the
|
|
15
|
+
// hand-maintained copies in the bridge (src/ssrf.js) and the extension
|
|
16
|
+
// (js/context.js isBlockedHost) so a security guard can't silently drift between
|
|
17
|
+
// the direct client path and the proxied path. See docs/secure-data-plane.md.
|
|
18
|
+
//
|
|
19
|
+
// The policy knobs cover the two legitimate trust contexts:
|
|
20
|
+
// • A MODEL / API / MCP endpoint (gateway upstream, bridge MCP proxy) may live on
|
|
21
|
+
// loopback (Ollama, LM Studio) or the LAN (a homelab GPU box) — so those are
|
|
22
|
+
// allowed by default — but must NEVER reach cloud instance metadata.
|
|
23
|
+
// • A WEB PAGE fetch (link title, page context) has no business touching loopback
|
|
24
|
+
// or any private host at all — call with { allowLoopback:false, allowPrivate:false }.
|
|
25
|
+
// Cloud metadata (169.254.169.254 & friends) and non-http(s) schemes are blocked in
|
|
26
|
+
// BOTH contexts, unconditionally. Re-run the assert on every redirect hop.
|
|
27
|
+
|
|
28
|
+
function ipv4(h) {
|
|
29
|
+
const m = String(h).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
const o = m.slice(1).map(Number);
|
|
32
|
+
if (o.some((n) => n > 255)) return null;
|
|
33
|
+
return o;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const norm = (hostname) => String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
37
|
+
|
|
38
|
+
// Loopback = this host's own services (127.0.0.0/8, ::1, localhost, *.localhost).
|
|
39
|
+
export function isLoopbackHost(hostname) {
|
|
40
|
+
const h = norm(hostname);
|
|
41
|
+
if (!h) return false;
|
|
42
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
43
|
+
if (h === '::1') return true;
|
|
44
|
+
const o = ipv4(h);
|
|
45
|
+
return !!(o && o[0] === 127);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Cloud instance metadata — the sharpest SSRF target (credential theft). Covers the
|
|
49
|
+
// link-local IMDS address used by AWS/GCP/Azure/DO (169.254.169.254), Alibaba's
|
|
50
|
+
// 100.100.100.200, and the GCP/name-based metadata hosts. ALWAYS blocked.
|
|
51
|
+
export function isMetadataHost(hostname) {
|
|
52
|
+
const h = norm(hostname);
|
|
53
|
+
if (h === 'metadata.google.internal' || h === 'metadata') return true;
|
|
54
|
+
const o = ipv4(h);
|
|
55
|
+
if (!o) return false;
|
|
56
|
+
if (o[0] === 169 && o[1] === 254) return true; // 169.254.169.254 (+ link-local)
|
|
57
|
+
if (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200) return true; // Alibaba IMDS
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Private / internal address space, EXCLUDING loopback + metadata (checked
|
|
62
|
+
// separately): RFC1918, CGNAT, IPv6 ULA/link-local, mDNS .local, this-host 0.x/::.
|
|
63
|
+
export function isPrivateHost(hostname) {
|
|
64
|
+
const h = norm(hostname);
|
|
65
|
+
if (!h) return true;
|
|
66
|
+
if (h.endsWith('.local')) return true;
|
|
67
|
+
if (
|
|
68
|
+
h === '::' || h.startsWith('fc') || h.startsWith('fd') // IPv6 ULA
|
|
69
|
+
|| h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb') // link-local
|
|
70
|
+
) return true;
|
|
71
|
+
const o = ipv4(h);
|
|
72
|
+
if (o) {
|
|
73
|
+
const [a, b] = o;
|
|
74
|
+
if (a === 0 || a === 10) return true; // this-host / RFC1918
|
|
75
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
76
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
77
|
+
if (a === 192 && b === 168) return true; // RFC1918
|
|
78
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Policy-driven classifier. Returns true if `hostname` must be blocked under `policy`.
|
|
84
|
+
// Defaults model the ENDPOINT context (loopback + LAN allowed, metadata never).
|
|
85
|
+
export function isBlockedHost(hostname, { allowLoopback = true, allowPrivate = true } = {}) {
|
|
86
|
+
const h = norm(hostname);
|
|
87
|
+
if (!h) return true;
|
|
88
|
+
if (isMetadataHost(h)) return true; // never, in any context
|
|
89
|
+
if (isLoopbackHost(h)) return !allowLoopback;
|
|
90
|
+
if (isPrivateHost(h)) return !allowPrivate;
|
|
91
|
+
return false; // public host
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Assert a URL is fetchable under `policy`; returns the parsed URL or throws.
|
|
95
|
+
// Call on the initial URL AND after every redirect hop.
|
|
96
|
+
export function assertFetchableUrl(u, policy = {}) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try { parsed = new URL(u); } catch { throw new Error(`invalid URL: ${u}`); }
|
|
99
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
100
|
+
throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
|
|
101
|
+
}
|
|
102
|
+
if (isBlockedHost(parsed.hostname, policy)) {
|
|
103
|
+
throw new Error(`refusing to reach a blocked address (${parsed.hostname})`);
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Endpoint context: model/API/MCP upstream — loopback + LAN OK, metadata never.
|
|
109
|
+
export const assertEndpointUrl = (u, opts = {}) => assertFetchableUrl(u, { allowLoopback: true, allowPrivate: true, ...opts });
|
|
110
|
+
// Web-page context: no loopback, no private, no metadata — genuinely public only.
|
|
111
|
+
export const assertPublicWebUrl = (u) => assertFetchableUrl(u, { allowLoopback: false, allowPrivate: false });
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-pii/pii-detect.js (npm @chatpanel/pii).
|
|
3
|
+
// Edit there, then run: npm run sync:pii
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve.
|
|
8
|
+
|
|
9
|
+
// Phase 2: configurable, LOCAL entity detection.
|
|
10
|
+
//
|
|
11
|
+
// Produces [{value, type}] spans that feed the redaction engine, so names / orgs /
|
|
12
|
+
// IDs get redacted WITHOUT a hand-maintained dictionary. Detection runs on-device
|
|
13
|
+
// only — the detector is a local NER service (spaCy / Presidio / any HTTP service)
|
|
14
|
+
// or a local LLM (OpenAI-compatible, e.g. a gemma served by llama.cpp). Raw text
|
|
15
|
+
// reaches the detector but never the final agent; only the redacted text does.
|
|
16
|
+
//
|
|
17
|
+
// Performance / flexibility (the whole point):
|
|
18
|
+
// - backends are pluggable and user-configured (URL + model + timeout).
|
|
19
|
+
// - a content-hash cache avoids re-detecting unchanged text.
|
|
20
|
+
// - a per-call timeout + fail-open means a slow/broken detector NEVER blocks the
|
|
21
|
+
// chat — redaction silently falls back to the deterministic layer.
|
|
22
|
+
// - input is length-capped so a huge transcript can't stall detection.
|
|
23
|
+
|
|
24
|
+
import { assertEndpointUrl } from './net.js';
|
|
25
|
+
|
|
26
|
+
const cache = new Map(); // key -> [{value,type}]
|
|
27
|
+
const CACHE_MAX = 300;
|
|
28
|
+
|
|
29
|
+
export function clearDetectCache() { cache.clear(); }
|
|
30
|
+
|
|
31
|
+
function cacheKey(text, det) {
|
|
32
|
+
let h = 5381;
|
|
33
|
+
const s = `${det?.backend}|${det?.url}|${det?.model}|${text}`;
|
|
34
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
35
|
+
return `${s.length}:${h}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function withTimeout(promise, ms, signal) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const timer = setTimeout(() => reject(new Error('detect timeout')), Math.max(200, ms || 1500));
|
|
41
|
+
const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')); };
|
|
42
|
+
if (signal) signal.addEventListener?.('abort', onAbort, { once: true });
|
|
43
|
+
promise.then(
|
|
44
|
+
(v) => { clearTimeout(timer); resolve(v); },
|
|
45
|
+
(e) => { clearTimeout(timer); reject(e); },
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Map common NER labels (spaCy, HF, Presidio) onto our placeholder types.
|
|
51
|
+
function normType(t) {
|
|
52
|
+
const s = String(t || 'ENTITY').toUpperCase().replace(/[^A-Z0-9]/g, '') || 'ENTITY';
|
|
53
|
+
const map = {
|
|
54
|
+
PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON',
|
|
55
|
+
ORG: 'ORG', ORGANIZATION: 'ORG',
|
|
56
|
+
GPE: 'LOCATION', LOC: 'LOCATION', LOCATION: 'LOCATION',
|
|
57
|
+
NORP: 'GROUP', EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL',
|
|
58
|
+
PHONE: 'PHONE', PHONENUMBER: 'PHONE',
|
|
59
|
+
};
|
|
60
|
+
return map[s] || s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Identifiers we ALWAYS redact (also caught deterministically). The user-facing
|
|
64
|
+
// category toggles (person/org/location/number) control the rest, so geography
|
|
65
|
+
// questions still work if "location" is turned off, etc. Numeric/temporal labels
|
|
66
|
+
// (DATE, CARDINAL, ORDINAL…) are noisy — small NER models tag "today" / "4" — so
|
|
67
|
+
// they only count when the value is a long digit run (phone/account/ID).
|
|
68
|
+
const ALWAYS_KEEP = new Set(['EMAIL', 'PHONE', 'SSN', 'CREDITCARD', 'IBAN', 'ID']);
|
|
69
|
+
const LOCATION_TYPES = new Set(['LOCATION', 'FAC', 'ADDRESS', 'GROUP', 'NRP']);
|
|
70
|
+
|
|
71
|
+
function keepEntity(value, type, types) {
|
|
72
|
+
const on = (k) => !types || types[k] !== false; // default on
|
|
73
|
+
if (ALWAYS_KEEP.has(type)) return true;
|
|
74
|
+
if (type === 'PERSON') return on('person');
|
|
75
|
+
if (type === 'ORG') return on('org');
|
|
76
|
+
if (LOCATION_TYPES.has(type)) return on('location');
|
|
77
|
+
const digits = (String(value).match(/\d/g) || []).length;
|
|
78
|
+
return digits >= 7 ? on('number') : false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Normalize the many detector response shapes to [{value, type}], de-duplicated.
|
|
82
|
+
// `types` (optional) is the user's category toggles {person,org,location,number}.
|
|
83
|
+
export function normalizeEntities(data, types) {
|
|
84
|
+
let list = [];
|
|
85
|
+
if (Array.isArray(data)) list = data;
|
|
86
|
+
else if (data && Array.isArray(data.entities)) list = data.entities;
|
|
87
|
+
else if (data && Array.isArray(data.ents)) list = data.ents; // spaCy displacy
|
|
88
|
+
else if (data && Array.isArray(data.results)) list = data.results; // Presidio
|
|
89
|
+
const out = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const e of list) {
|
|
92
|
+
if (!e) continue;
|
|
93
|
+
const value = String(e.value ?? e.text ?? e.entity ?? e.word ?? '').trim();
|
|
94
|
+
const type = normType(e.type ?? e.label ?? e.entity_group ?? e.entity_type ?? e.tag);
|
|
95
|
+
if (!value || value.length > 200 || !keepEntity(value, type, types)) continue;
|
|
96
|
+
const k = `${type}:${value.toLowerCase()}`;
|
|
97
|
+
if (seen.has(k)) continue;
|
|
98
|
+
seen.add(k);
|
|
99
|
+
out.push({ value, type });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseJsonLoose(s) {
|
|
105
|
+
if (!s) return null;
|
|
106
|
+
const a = String(s).indexOf('{');
|
|
107
|
+
const b = String(s).lastIndexOf('}');
|
|
108
|
+
if (a < 0 || b <= a) return null;
|
|
109
|
+
try { return JSON.parse(String(s).slice(a, b + 1)); } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const EXTRACT_SYS = 'You extract sensitive entities from text for redaction. '
|
|
113
|
+
+ 'Return ONLY JSON: {"entities":[{"value":"<verbatim text>","type":"PERSON|ORG|LOCATION|ID|EMAIL|PHONE|OTHER"}]}. '
|
|
114
|
+
+ 'Copy each value exactly as it appears. Include people, organizations, locations, and account/ID numbers. No commentary, no code fences.';
|
|
115
|
+
|
|
116
|
+
async function detectViaEndpoint(text, det, signal, fetchImpl) {
|
|
117
|
+
const res = await fetchImpl(det.url, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
|
|
120
|
+
body: JSON.stringify({ text }),
|
|
121
|
+
signal,
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) throw new Error(`detect HTTP ${res.status}`);
|
|
124
|
+
return normalizeEntities(await res.json(), det.types);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function detectViaOpenAI(text, det, signal, fetchImpl) {
|
|
128
|
+
const base = String(det.url || '').replace(/\/$/, '');
|
|
129
|
+
// Build the chat URL the SAME way the chat path does. An OpenAI-compatible baseUrl
|
|
130
|
+
// already ends in /v1 (Ollama, OpenRouter, NVIDIA, OpenAI…) → only add
|
|
131
|
+
// /chat/completions (appending /v1/chat/completions would 404 on /v1/v1/…). A bare
|
|
132
|
+
// host gets /v1/chat/completions; a full chat URL is used as-is.
|
|
133
|
+
const url = /\/chat\/completions$/.test(base) ? base
|
|
134
|
+
: /\/v\d+$/.test(base) ? `${base}/chat/completions`
|
|
135
|
+
: `${base}/v1/chat/completions`;
|
|
136
|
+
const res = await fetchImpl(url, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
model: det.model || 'local',
|
|
141
|
+
temperature: 0,
|
|
142
|
+
max_tokens: det.maxTokens || 256,
|
|
143
|
+
messages: [{ role: 'system', content: EXTRACT_SYS }, { role: 'user', content: text }],
|
|
144
|
+
}),
|
|
145
|
+
signal,
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) throw new Error(`detect HTTP ${res.status}`);
|
|
148
|
+
const json = await res.json();
|
|
149
|
+
const content = json?.choices?.[0]?.message?.content ?? json?.content ?? '';
|
|
150
|
+
return normalizeEntities(parseJsonLoose(content), det.types);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Returns [{value, type}] spans for `text`, or [] (fail-open) on any error/timeout.
|
|
154
|
+
export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis.fetch, strict = false } = {}) {
|
|
155
|
+
const det = cfg?.detection;
|
|
156
|
+
if (!det || !det.backend || det.backend === 'off' || !det.url || typeof fetchImpl !== 'function') return [];
|
|
157
|
+
const capped = String(text || '').slice(0, det.maxChars || 8000);
|
|
158
|
+
if (capped.trim().length < 8) return [];
|
|
159
|
+
const key = cacheKey(capped, det);
|
|
160
|
+
if (!strict && cache.has(key)) return cache.get(key);
|
|
161
|
+
const run = det.backend === 'endpoint' ? detectViaEndpoint : detectViaOpenAI;
|
|
162
|
+
let ents = [];
|
|
163
|
+
try {
|
|
164
|
+
// SSRF guard before RAW (pre-redaction) text leaves for the detector: http(s)
|
|
165
|
+
// only, never cloud metadata. Loopback/LAN allowed — a local NER server / Ollama
|
|
166
|
+
// is the normal case. A blocked URL fails open (deterministic-only), or surfaces
|
|
167
|
+
// to the Test button in strict mode.
|
|
168
|
+
assertEndpointUrl(det.url);
|
|
169
|
+
ents = await withTimeout(run(capped, det, signal, fetchImpl), det.timeoutMs || 1500, signal);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (strict) throw e; // surface errors to the Test button
|
|
172
|
+
ents = []; // otherwise fail open — deterministic redaction still applies
|
|
173
|
+
}
|
|
174
|
+
if (cache.size >= CACHE_MAX) cache.clear();
|
|
175
|
+
if (!strict) cache.set(key, ents);
|
|
176
|
+
return ents;
|
|
177
|
+
}
|