@chatpanel/bridge 0.10.42 → 0.11.1

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/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
+ }