@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.
@@ -0,0 +1,179 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/sanitize.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
+ // Unicode de-steganography for text that flows through the privacy boundary.
10
+ //
11
+ // Invisible and format-control characters are a single vector with three abuses,
12
+ // all relevant to a redaction product:
13
+ //
14
+ // 1. Redaction bypass - splitting a value with zero-width chars (j<ZWSP>o<ZWSP>hn@x.com)
15
+ // hides it from the regex/NER detector, then the model reassembles the real
16
+ // value. The deterministic engine works on text, so the smuggled PII leaks.
17
+ // 2. Hidden prompt injection - Unicode Tag characters (U+E0000-E007F) render as
18
+ // nothing but encode a full ASCII instruction the model reads ("ASCII smuggling").
19
+ // 3. Fingerprinting / watermarking - steganographic markers injected into a prompt
20
+ // (e.g. a client classifying a custom gateway and encoding a bit into invisible
21
+ // punctuation). A privacy proxy should scrub these - and never emit its own.
22
+ //
23
+ // We strip the channels that have no legitimate place in plain prompt text, while
24
+ // PRESERVING the few legitimate uses (emoji ZWJ/variation sequences, normal accents).
25
+ //
26
+ // The patterns are BUILT FROM NUMERIC CODE POINTS below - there are deliberately no
27
+ // literal invisible characters anywhere in this source (auditable, and fitting for a
28
+ // de-steg module). Pure + dependency-free ESM. Call it BEFORE detection so obfuscated
29
+ // PII becomes matchable, and on model output before restoration so a token can't be
30
+ // split/spoofed with invisibles.
31
+
32
+ // Code-point ranges (inclusive) per category, by their abuse.
33
+ const RANGES = {
34
+ // Unicode Tag block - the ASCII-smuggling channel.
35
+ tags: [[0xE0000, 0xE007F]],
36
+ // Bidi controls - reorder/override visible text to hide reversed instructions.
37
+ bidi: [[0x061C, 0x061C], [0x200E, 0x200F], [0x202A, 0x202E], [0x2066, 0x2069]],
38
+ // Zero-width & assorted invisible format chars: soft hyphen, Hangul/Mongolian
39
+ // fillers, ZWSP, word/invisible joiners, deprecated format controls, BOM/ZWNBSP,
40
+ // interlinear annotation, object replacement.
41
+ zeroWidth: [
42
+ [0x00AD, 0x00AD], [0x115F, 0x1160], [0x180E, 0x180E], [0x200B, 0x200B],
43
+ [0x2060, 0x2064], [0x206A, 0x206F], [0x3164, 0x3164], [0xFEFF, 0xFEFF],
44
+ [0xFFA0, 0xFFA0], [0xFFF9, 0xFFFB], [0xFFFC, 0xFFFC],
45
+ ],
46
+ // Supplementary variation selectors - the byte-smuggling range. Never legit in text.
47
+ supVS: [[0xE0100, 0xE01EF]],
48
+ // Line/paragraph separators - converted to '\n' (kill parser tricks, keep the break).
49
+ lineSep: [[0x2028, 0x2029]],
50
+ // ZWJ/ZWNJ + BMP variation selectors - legit ONLY next to an emoji base, so these
51
+ // are stripped contextually (see ANOMALOUS_JOIN_VS), not unconditionally.
52
+ joinVS: [[0x200C, 0x200D], [0xFE00, 0xFE0F]],
53
+ };
54
+
55
+ const u = (cp) => `\\u{${cp.toString(16).toUpperCase()}}`;
56
+ const cls = (ranges) => ranges.map(([a, b]) => (a === b ? u(a) : `${u(a)}-${u(b)}`)).join('');
57
+
58
+ const TAGS = new RegExp(`[${cls(RANGES.tags)}]`, 'gu');
59
+ const BIDI = new RegExp(`[${cls(RANGES.bidi)}]`, 'gu');
60
+ const ZERO_WIDTH = new RegExp(`[${cls(RANGES.zeroWidth)}]`, 'gu');
61
+ const SUP_VS = new RegExp(`[${cls(RANGES.supVS)}]`, 'gu');
62
+ const LINE_SEP = new RegExp(`[${cls(RANGES.lineSep)}]`, 'gu');
63
+ // Strip ZWJ/ZWNJ/VS only when NOT preceded by an emoji base (so emoji sequences and
64
+ // regional-indicator flags survive); supplementary VS are always stripped.
65
+ const ANOMALOUS_JOIN_VS = new RegExp(
66
+ `(?<![\\p{Extended_Pictographic}${u(0x1F1E6)}-${u(0x1F1FF)}])[${cls(RANGES.joinVS)}]|[${cls(RANGES.supVS)}]`,
67
+ 'gu',
68
+ );
69
+ // Runs of combining marks (Zalgo / bit-stuffing). A real stacked diacritic is 1-3
70
+ // marks; anything past the cap is signalling, not language.
71
+ const COMBINING_RUN = /\p{M}+/gu;
72
+
73
+ // Cheap boolean for hot paths / UI ("does this contain anything hidden?"). Excludes
74
+ // the context-dependent joinVS so legitimate emoji aren't flagged - sanitizeUnicode()
75
+ // stays the source of truth for those.
76
+ const ANY_HIDDEN = new RegExp(
77
+ `[${cls(RANGES.bidi)}${cls(RANGES.zeroWidth)}]|[${cls(RANGES.tags)}]|[${cls(RANGES.supVS)}]`,
78
+ 'u',
79
+ );
80
+
81
+ export function hasHiddenChars(text) {
82
+ return typeof text === 'string' && ANY_HIDDEN.test(text);
83
+ }
84
+
85
+ // sanitizeUnicode(text, opts) -> { clean, removed, findings }
86
+ // clean - text with the smuggling channels stripped/normalized
87
+ // removed - total count of stripped/collapsed characters (0 = nothing hidden)
88
+ // findings - per-category counts (only non-zero keys), for transparent reporting
89
+ //
90
+ // opts.normalize: 'NFC' (default, appearance-preserving) | 'NFKC' (also folds
91
+ // fullwidth/homoglyph compatibility forms - stronger for detection, but rewrites
92
+ // some visible glyphs) | 'none'.
93
+ // opts.collapseCombiningOver: max combining marks kept per run (default 4).
94
+ export function sanitizeUnicode(text, { normalize = 'NFC', collapseCombiningOver = 4 } = {}) {
95
+ if (typeof text !== 'string' || text === '') return { clean: text ?? '', removed: 0, findings: {} };
96
+ let s = text;
97
+ const findings = {};
98
+
99
+ // Strip one category, counting by code point (spread iterates code points, so a
100
+ // supplementary char like a Tag counts as 1, not 2 UTF-16 units).
101
+ const strip = (re, key) => {
102
+ let n = 0;
103
+ s = s.replace(re, (m) => { n += [...m].length; return ''; });
104
+ if (n) findings[key] = n;
105
+ };
106
+
107
+ let lineSep = 0;
108
+ s = s.replace(LINE_SEP, () => { lineSep++; return '\n'; });
109
+ if (lineSep) findings.lineSep = lineSep;
110
+
111
+ strip(TAGS, 'tags');
112
+ strip(BIDI, 'bidi');
113
+ strip(ZERO_WIDTH, 'zeroWidth');
114
+ strip(ANOMALOUS_JOIN_VS, 'joinersVS');
115
+
116
+ // Compose canonically so split/decomposed forms can't dodge the detector.
117
+ if (normalize && normalize !== 'none') {
118
+ try { s = s.normalize(normalize); } catch { /* invalid form name -> skip */ }
119
+ }
120
+
121
+ let combining = 0;
122
+ s = s.replace(COMBINING_RUN, (run) => {
123
+ const marks = [...run];
124
+ if (marks.length <= collapseCombiningOver) return run;
125
+ combining += marks.length - collapseCombiningOver;
126
+ return marks.slice(0, collapseCombiningOver).join('');
127
+ });
128
+ if (combining) findings.combining = combining;
129
+
130
+ const removed = (findings.tags || 0) + (findings.bidi || 0) + (findings.zeroWidth || 0)
131
+ + (findings.joinersVS || 0) + (findings.combining || 0);
132
+ return { clean: s, removed, findings };
133
+ }
134
+
135
+ // Convenience for the common "just give me clean text" caller.
136
+ export function stripHidden(text, opts) {
137
+ return sanitizeUnicode(text, opts).clean;
138
+ }
139
+
140
+ // ── Confusables skeleton ─────────────────────────────────────────────────────
141
+ // Fold single-code-point Latin LOOK-ALIKES (Cyrillic / Greek / fullwidth) to their
142
+ // ASCII skeleton so a homoglyph-obfuscated value (jоhn@x.com with a Cyrillic 'о')
143
+ // becomes matchable by the ASCII regexes. STRICTLY 1:1 per code point — every mapping
144
+ // is one char → one char — so a match's indices in the skeleton line up exactly with
145
+ // the original text. Use it for DETECTION only and redact the ORIGINAL span, so
146
+ // legitimate Cyrillic/Greek/CJK text is never rewritten (only deceptively-Latin
147
+ // values that actually match a detector get touched). Built from numeric code points
148
+ // (no literal confusables in source, like the rest of this module).
149
+ const CONFUSABLE = new Map([
150
+ // Cyrillic lowercase → Latin
151
+ [0x0430, 'a'], [0x0435, 'e'], [0x043E, 'o'], [0x0440, 'p'], [0x0441, 'c'],
152
+ [0x0443, 'y'], [0x0445, 'x'], [0x0455, 's'], [0x0456, 'i'], [0x0458, 'j'],
153
+ [0x04BB, 'h'], [0x043C, 'm'], [0x043D, 'h'], [0x0442, 't'], [0x043A, 'k'],
154
+ // Cyrillic uppercase → Latin
155
+ [0x0410, 'A'], [0x0412, 'B'], [0x0415, 'E'], [0x041A, 'K'], [0x041C, 'M'],
156
+ [0x041D, 'H'], [0x041E, 'O'], [0x0420, 'P'], [0x0421, 'C'], [0x0422, 'T'],
157
+ [0x0425, 'X'], [0x0406, 'I'], [0x0408, 'J'], [0x0405, 'S'],
158
+ // Greek → Latin
159
+ [0x03BF, 'o'], [0x03C1, 'p'], [0x03B1, 'a'], [0x03BD, 'v'], [0x03B9, 'i'],
160
+ [0x0391, 'A'], [0x0392, 'B'], [0x0395, 'E'], [0x0396, 'Z'], [0x0397, 'H'],
161
+ [0x0399, 'I'], [0x039A, 'K'], [0x039C, 'M'], [0x039D, 'N'], [0x039F, 'O'],
162
+ [0x03A1, 'P'], [0x03A4, 'T'], [0x03A5, 'Y'], [0x03A7, 'X'],
163
+ ]);
164
+
165
+ export function confusablesSkeleton(text) {
166
+ if (typeof text !== 'string' || text === '') return text ?? '';
167
+ let out = '';
168
+ for (const ch of text) {
169
+ const cp = ch.codePointAt(0);
170
+ if (cp >= 0xFF01 && cp <= 0xFF5E) { out += String.fromCharCode(cp - 0xFEE0); continue; } // fullwidth ASCII
171
+ const mapped = CONFUSABLE.get(cp);
172
+ out += mapped != null ? mapped : ch;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // Exposed for tests / external auditing.
178
+ export const SANITIZE_RANGES = RANGES;
179
+ export const CONFUSABLE_MAP = CONFUSABLE;
@@ -0,0 +1,152 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/tool-harness.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
+ // THE tool harness — one interception layer shared by every orchestrator
10
+ // (ChatPanel API + agent, gateway API + relay) so tool handling can't drift. It
11
+ // owns the boundaries around a model turn:
12
+ //
13
+ // ⓪ selectTools — inject + narrow the tools the model is offered (MCP-auto).
14
+ // ② toTool — what a tool RECEIVES: real values (so on-device / remote
15
+ // lookups work), or the redacted token for remote MCP tools
16
+ // when the user chose "redact remote".
17
+ // ③ toModelResult— what the MODEL sees back: the tool result re-redacted so it
18
+ // stays blinded.
19
+ // ④ toUser — the final reply: reversible tokens restored (pseudonyms stay).
20
+ //
21
+ // PRIVACY IS OPTIONAL. With no `vault` (redaction off), ②③④ are pass-throughs —
22
+ // no latency, no placeholder confusion — but ⓪ selectTools STILL narrows, because
23
+ // not every turn is privacy-sensitive yet every turn benefits from fewer tools.
24
+ //
25
+ // Self-contained on the SYNCED engine files (pii-redact.js, tool-rank.js), so the
26
+ // extension (browser ESM) and the gateway (npm) run the exact same code. The caller
27
+ // passes the already-gated `redactOpts` ({tier, entities, dictionary}) it computed
28
+ // from cfg+isPro — tier/dictionary selection stays out of the harness.
29
+
30
+ import { restoreText, restoreWithAliases, redactResultShape } from './pii-redact.js';
31
+ import { narrowSpecs } from './tool-rank.js';
32
+
33
+ // MCP / remote tools are server-prefixed (mcp_server__tool). Local tools
34
+ // (history/meeting/page, or a client's core bash/read) are not — they always get
35
+ // real values and are never narrowed away.
36
+ export const isRemoteToolName = (name) => /^mcp[_-]/i.test(String(name || ''));
37
+
38
+ // Deep restore of a tool-call argument value, undoing reversible tokens AND
39
+ // pseudonyms (tools run locally / on real data; only the model stays blinded).
40
+ export function restoreToolArgs(value, vault) {
41
+ if (!vault) return value;
42
+ if (typeof value === 'string') return restoreWithAliases(value, vault);
43
+ if (Array.isArray(value)) return value.map((v) => restoreToolArgs(v, vault));
44
+ if (value && typeof value === 'object') {
45
+ const out = {};
46
+ for (const k of Object.keys(value)) out[k] = restoreToolArgs(value[k], vault);
47
+ return out;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ // System-prompt note that tells the model how to behave around placeholders when
53
+ // tools are armed. WITHOUT this, privacy-aware models (Codex, Claude) recognize a
54
+ // [[LOCATION_1]] token as redacted and REFUSE to use it for a lookup ("I can't see
55
+ // your real city") — the opposite of what we want. Weak models call the tool blindly
56
+ // and it works (the harness restores the real value), so the note levels them up.
57
+ export function placeholderToolNote({ toolData = 'real' } = {}) {
58
+ const intro =
59
+ 'PRIVACY PLACEHOLDERS: some values in this conversation are tokens like [[PERSON_1]], '
60
+ + '[[LOCATION_1]], [[ORG_1]] that stand in for the user\'s real private data. ';
61
+ const remote = toolData === 'redactRemote'
62
+ ? 'When you call a LOCAL tool the placeholder is automatically replaced with the real '
63
+ + 'value before the tool runs; REMOTE (MCP) tools deliberately receive the placeholder '
64
+ + 'to keep private data off third-party servers. '
65
+ : 'When you call ANY tool, these placeholders are AUTOMATICALLY replaced with the real '
66
+ + 'values before the tool executes — the tool receives the TRUE value and returns correct '
67
+ + 'results. ';
68
+ const rules =
69
+ 'So: treat each placeholder as a CONCRETE, specific value you already have — it is enough to '
70
+ + 'act on, NOT missing or unknown information. CALL THE TOOL using the placeholder exactly as '
71
+ + 'written, as if it were the real value. '
72
+ // The common failure isn\'t a privacy refusal — it\'s the model deciding it "lacks data"
73
+ // because the value is a token, and answering from general knowledge instead of looking up.
74
+ + 'If answering needs the real data behind a placeholder (e.g. which city [[LOCATION_1]] is, '
75
+ + 'who [[PERSON_1]] is, what [[ORG_1]] does), do NOT reply that you lack information or cannot '
76
+ + 'answer. Instead pick the most relevant available tool for that placeholder\'s TYPE — LOCATION '
77
+ + '→ geography / place lookups, PERSON → people lookups, ORG → company/org lookups, dates/IDs → '
78
+ + 'the matching lookup — and pass the placeholder straight through as the argument. The harness '
79
+ + 'restores the true value before the tool runs, so the lookup returns correct results. Make your '
80
+ + 'best-guess tool call FIRST; only conclude you lack data AFTER a tool has actually returned '
81
+ + 'nothing useful. '
82
+ + 'Do NOT ask the user to re-type the value and do NOT refuse on privacy grounds — the lookup '
83
+ + 'will work. The real values are restored in your final answer automatically, so write your '
84
+ + 'answer using the placeholders too.';
85
+ return intro + remote + rules;
86
+ }
87
+
88
+ // Tools whose results come from the PUBLIC web rather than from the user's own machine or
89
+ // accounts. Deliberately a short, explicit allowlist rather than a heuristic: being wrong in
90
+ // the "public" direction would send real PII to a model, so a tool earns its place here only
91
+ // when its output is public by construction. Page tools are NOT here — the user's open tab
92
+ // may be an internal app.
93
+ const PUBLIC_SOURCE_TOOLS = new Set(['web_search', 'web_fetch', 'fetch_url']);
94
+ export function isPublicSourceTool(name) {
95
+ return PUBLIC_SOURCE_TOOLS.has(String(name || '').toLowerCase());
96
+ }
97
+
98
+ export function makeToolHarness({ vault = null, toolData = 'real', redactOpts = null, redactResults = true, remoteTools = null } = {}) {
99
+ const on = !!vault; // privacy enabled for this turn?
100
+ const redactRemote = toolData === 'redactRemote';
101
+ // How we decide a tool is REMOTE (must not receive real PII under redactRemote).
102
+ // Prefer an EXPLICIT set/predicate the caller derived from the toolset (a remote
103
+ // tool not named mcp_* would otherwise be misclassified as local and get real
104
+ // values); fall back to the mcp_* name heuristic when the caller passes nothing.
105
+ const isRemoteTool = typeof remoteTools === 'function' ? remoteTools
106
+ : (remoteTools instanceof Set ? (name) => remoteTools.has(name)
107
+ : isRemoteToolName);
108
+ return {
109
+ enabled: on,
110
+ isRemoteTool,
111
+
112
+ // ⓪ Always-on tool selection (privacy-independent). `available` is any spec
113
+ // list; `opts` forwards { cap, keep, name, description } to the shared ranker.
114
+ selectTools(available, query, opts = {}) {
115
+ return narrowSpecs(available, query, opts);
116
+ },
117
+
118
+ // ② What the tool receives.
119
+ toTool(name, args) {
120
+ if (!on) return args; // privacy off → already real
121
+ if (redactRemote && isRemoteTool(name)) return args; // keep PII off remote MCP
122
+ return restoreToolArgs(args, vault); // real values for the tool
123
+ },
124
+
125
+ // ③ What the model sees back (re-redacted). Walks string / { text } / array /
126
+ // MCP { content:[{text}] } shapes so a tool result can't leak PII to the model
127
+ // via a nested field the old string/{text}-only path skipped.
128
+ toModelResult(name, raw) {
129
+ if (!on || !redactResults || !redactOpts) return raw;
130
+ // PUBLIC RESULTS ARE NOT THE USER'S DATA.
131
+ //
132
+ // Redaction exists to stop the user's information LEAVING the device. Text coming back
133
+ // from a public web search never left it — the model's provider could fetch the same
134
+ // page itself — so rewriting it buys no privacy and actively corrupts facts: a
135
+ // dictionary pseudonym (a user's own name → a stand-in) renamed a same-named public
136
+ // figure inside search results, and the answer came back about a person who does not
137
+ // exist. The detectors
138
+ // (emails, phones, keys) also fire on unrelated strangers' details in fetched pages.
139
+ //
140
+ // So public-source results pass through intact. Everything local or private — history,
141
+ // meetings, notes, the user's own page, any MCP server — is redacted exactly as before,
142
+ // which is where a leak could actually happen.
143
+ if (isPublicSourceTool(name)) return raw;
144
+ return redactResultShape(raw, vault, redactOpts);
145
+ },
146
+
147
+ // ④ The final reply the user sees.
148
+ toUser(text) {
149
+ return on ? restoreText(text, vault) : text;
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,110 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/tool-rank.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
+ // Deterministic, model-free tool ranking — shared by the extension's side panel
10
+ // and the gateway, so "auto mode" narrows the same way everywhere (single source
11
+ // of truth, per the no-duplication rule).
12
+ //
13
+ // Ranks tool specs by lexical relevance to a query, weighting each query word by
14
+ // INVERSE DOCUMENT FREQUENCY across the toolset: a distinctive word like "wiki"
15
+ // (in 1–2 tools) counts far more than a common one like "search" (in many) — so
16
+ // "use wiki search" ranks the Wikipedia tool above generic search tools instead
17
+ // of tying them. Latency-sensitive: pure string ops, runs on every turn, no model
18
+ // call. Generic over the spec shape via name/description accessors (the extension
19
+ // uses { name, description }; the gateway uses OpenAI's { function: { name, … } }).
20
+
21
+ const STOP = new Set([
22
+ 'the', 'and', 'for', 'with', 'that', 'this', 'use', 'can', 'you', 'your', 'please',
23
+ 'about', 'from', 'what', 'who', 'how', 'are', 'was', 'will', 'just', 'tell', 'find',
24
+ 'get', 'into', 'them', 'they', 'their', 'name', 'one', 'but', 'not', 'all',
25
+ ]);
26
+
27
+ const defName = (s) => (s && s.name) || '';
28
+ const defDesc = (s) => (s && s.description) || '';
29
+
30
+ // Names of GENERAL entry-point tools — preferred when a query doesn't pin a specific
31
+ // tool. Matches the tool segment (after the server prefix): e.g. ...__wikipedia_search,
32
+ // ...__ask_pipeworx, ...__get_summary, ...__search_wikipedia.
33
+ const GENERAL_TOOL_RE = /(?:^|_)(search|ask|lookup|find|answer|summary|wiki)(?:_|$)/i;
34
+
35
+ // Returns specs scored + sorted most-relevant first, as [{ s, i, n }] (i = original
36
+ // index, n = score). Stable for ties (preserves original order).
37
+ export function scoreToolSpecs(specs, query, { name = defName, description = defDesc } = {}) {
38
+ const q = String(query || '').toLowerCase();
39
+ const words = [...new Set(q.split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)))];
40
+ const list = [...(specs || [])];
41
+ const names = list.map((s) => String(name(s) || '').toLowerCase());
42
+ const hays = list.map((s, i) => `${names[i]} ${String(description(s) || '').toLowerCase()}`);
43
+ const N = list.length || 1;
44
+ const df = {}; // how many tools mention each query word
45
+ for (const w of words) df[w] = hays.reduce((n, h) => n + (h.includes(w) ? 1 : 0), 0);
46
+ const idf = (w) => Math.log(1 + N / (1 + (df[w] || 0))); // rarer → higher weight
47
+ const score = (i) => {
48
+ let n = 0;
49
+ for (const w of words) if (hays[i].includes(w)) n += idf(w);
50
+ for (const part of names[i].split(/[^a-z0-9]+/)) {
51
+ if (part.length > 2 && q.includes(part)) n += 2 + idf(part); // tool explicitly named
52
+ }
53
+ // General-purpose ENTRY-POINT tools (search / ask / lookup / get-summary / answer)
54
+ // are the right default when the query doesn't keyword-match a specific tool —
55
+ // e.g. "which state is Seattle in" → a wikipedia SEARCH/ASK tool, not one of 20
56
+ // dataset-query tools. A small tie-breaker boost (below a real keyword match) so
57
+ // those generic tools win when nothing else distinguishes them.
58
+ if (GENERAL_TOOL_RE.test(names[i])) n += 1.5;
59
+ return n;
60
+ };
61
+ return list.map((s, i) => ({ s, i, n: score(i) })).sort((a, b) => (b.n - a.n) || (a.i - b.i));
62
+ }
63
+
64
+ // Rank tool specs most-relevant first (stable for ties).
65
+ export function rankToolSpecs(specs, query, accessors) {
66
+ return scoreToolSpecs(specs, query, accessors).map((x) => x.s);
67
+ }
68
+
69
+ // Narrow a flat spec list to at most `cap` entries that DON'T match `keep`, always
70
+ // retaining everything that does (e.g. local page/history tools). `cap` therefore
71
+ // bounds the NARROWABLE (MCP) tools; kept tools ride along free. Returns the list
72
+ // unchanged when there's no cap or the narrowable set already fits.
73
+ // The MCP server a tool belongs to: mcp_<server>__<tool> → "mcp_<server>". Tools
74
+ // without that shape are their own "server" (never grouped together).
75
+ function serverKey(n) {
76
+ const s = String(n || '');
77
+ const i = s.indexOf('__');
78
+ return i > 0 ? s.slice(0, i) : s;
79
+ }
80
+
81
+ export function narrowSpecs(specs, query, { cap = 0, keep, name = defName, description = defDesc } = {}) {
82
+ const list = specs || [];
83
+ if (!cap || cap < 1) return list;
84
+ const kept = keep ? list.filter(keep) : [];
85
+ const rest = keep ? list.filter((s) => !kept.includes(s)) : list;
86
+ if (rest.length <= cap) return list;
87
+ // SERVER-DIVERSE selection: rank all narrowable tools, then pick ROUND-ROBIN across
88
+ // servers — each server's best tool first, then seconds, … up to `cap`. This keeps
89
+ // a relevant server (e.g. wikipedia) from being crowded out of the top-K by another
90
+ // server that happens to have many tools. Servers are visited best-first (the order
91
+ // their top-ranked tool appears in the global ranking).
92
+ const ranked = rankToolSpecs(rest, query, { name, description });
93
+ const queues = new Map(); // serverKey -> [tools] in rank order (insertion = best-first)
94
+ for (const s of ranked) {
95
+ const k = serverKey(name(s));
96
+ if (!queues.has(k)) queues.set(k, []);
97
+ queues.get(k).push(s);
98
+ }
99
+ const lanes = [...queues.values()];
100
+ const chosen = new Set();
101
+ for (let round = 0; chosen.size < cap; round++) {
102
+ let advanced = false;
103
+ for (const lane of lanes) {
104
+ if (chosen.size >= cap) break;
105
+ if (lane.length > round) { chosen.add(lane[round]); advanced = true; }
106
+ }
107
+ if (!advanced) break;
108
+ }
109
+ return list.filter((s) => kept.includes(s) || chosen.has(s)); // preserve original order
110
+ }
package/src/sanitize.js CHANGED
@@ -1,137 +1,7 @@
1
- // VENDORED COPY of chatpanel-pii/sanitize.js - keep in sync with the canonical
2
- // engine (the bridge stays dependency-free, so this is a copy, not an import).
3
- // Regenerate by copying ../chatpanel-pii/sanitize.js over this file.
4
-
5
- // Unicode de-steganography for text that flows through the privacy boundary.
1
+ // The Unicode sanitizer, from the one engine that owns it.
6
2
  //
7
- // Invisible and format-control characters are a single vector with three abuses,
8
- // all relevant to a redaction product:
9
- //
10
- // 1. Redaction bypass - splitting a value with zero-width chars (j<ZWSP>o<ZWSP>hn@x.com)
11
- // hides it from the regex/NER detector, then the model reassembles the real
12
- // value. The deterministic engine works on text, so the smuggled PII leaks.
13
- // 2. Hidden prompt injection - Unicode Tag characters (U+E0000-E007F) render as
14
- // nothing but encode a full ASCII instruction the model reads ("ASCII smuggling").
15
- // 3. Fingerprinting / watermarking - steganographic markers injected into a prompt
16
- // (e.g. a client classifying a custom gateway and encoding a bit into invisible
17
- // punctuation). A privacy proxy should scrub these - and never emit its own.
18
- //
19
- // We strip the channels that have no legitimate place in plain prompt text, while
20
- // PRESERVING the few legitimate uses (emoji ZWJ/variation sequences, normal accents).
21
- //
22
- // The patterns are BUILT FROM NUMERIC CODE POINTS below - there are deliberately no
23
- // literal invisible characters anywhere in this source (auditable, and fitting for a
24
- // de-steg module). Pure + dependency-free ESM. Call it BEFORE detection so obfuscated
25
- // PII becomes matchable, and on model output before restoration so a token can't be
26
- // split/spoofed with invisibles.
27
-
28
- // Code-point ranges (inclusive) per category, by their abuse.
29
- const RANGES = {
30
- // Unicode Tag block - the ASCII-smuggling channel.
31
- tags: [[0xE0000, 0xE007F]],
32
- // Bidi controls - reorder/override visible text to hide reversed instructions.
33
- bidi: [[0x061C, 0x061C], [0x200E, 0x200F], [0x202A, 0x202E], [0x2066, 0x2069]],
34
- // Zero-width & assorted invisible format chars: soft hyphen, Hangul/Mongolian
35
- // fillers, ZWSP, word/invisible joiners, deprecated format controls, BOM/ZWNBSP,
36
- // interlinear annotation, object replacement.
37
- zeroWidth: [
38
- [0x00AD, 0x00AD], [0x115F, 0x1160], [0x180E, 0x180E], [0x200B, 0x200B],
39
- [0x2060, 0x2064], [0x206A, 0x206F], [0x3164, 0x3164], [0xFEFF, 0xFEFF],
40
- [0xFFA0, 0xFFA0], [0xFFF9, 0xFFFB], [0xFFFC, 0xFFFC],
41
- ],
42
- // Supplementary variation selectors - the byte-smuggling range. Never legit in text.
43
- supVS: [[0xE0100, 0xE01EF]],
44
- // Line/paragraph separators - converted to '\n' (kill parser tricks, keep the break).
45
- lineSep: [[0x2028, 0x2029]],
46
- // ZWJ/ZWNJ + BMP variation selectors - legit ONLY next to an emoji base, so these
47
- // are stripped contextually (see ANOMALOUS_JOIN_VS), not unconditionally.
48
- joinVS: [[0x200C, 0x200D], [0xFE00, 0xFE0F]],
49
- };
50
-
51
- const u = (cp) => `\\u{${cp.toString(16).toUpperCase()}}`;
52
- const cls = (ranges) => ranges.map(([a, b]) => (a === b ? u(a) : `${u(a)}-${u(b)}`)).join('');
53
-
54
- const TAGS = new RegExp(`[${cls(RANGES.tags)}]`, 'gu');
55
- const BIDI = new RegExp(`[${cls(RANGES.bidi)}]`, 'gu');
56
- const ZERO_WIDTH = new RegExp(`[${cls(RANGES.zeroWidth)}]`, 'gu');
57
- const SUP_VS = new RegExp(`[${cls(RANGES.supVS)}]`, 'gu');
58
- const LINE_SEP = new RegExp(`[${cls(RANGES.lineSep)}]`, 'gu');
59
- // Strip ZWJ/ZWNJ/VS only when NOT preceded by an emoji base (so emoji sequences and
60
- // regional-indicator flags survive); supplementary VS are always stripped.
61
- const ANOMALOUS_JOIN_VS = new RegExp(
62
- `(?<![\\p{Extended_Pictographic}${u(0x1F1E6)}-${u(0x1F1FF)}])[${cls(RANGES.joinVS)}]|[${cls(RANGES.supVS)}]`,
63
- 'gu',
64
- );
65
- // Runs of combining marks (Zalgo / bit-stuffing). A real stacked diacritic is 1-3
66
- // marks; anything past the cap is signalling, not language.
67
- const COMBINING_RUN = /\p{M}+/gu;
68
-
69
- // Cheap boolean for hot paths / UI ("does this contain anything hidden?"). Excludes
70
- // the context-dependent joinVS so legitimate emoji aren't flagged - sanitizeUnicode()
71
- // stays the source of truth for those.
72
- const ANY_HIDDEN = new RegExp(
73
- `[${cls(RANGES.bidi)}${cls(RANGES.zeroWidth)}]|[${cls(RANGES.tags)}]|[${cls(RANGES.supVS)}]`,
74
- 'u',
75
- );
76
-
77
- export function hasHiddenChars(text) {
78
- return typeof text === 'string' && ANY_HIDDEN.test(text);
79
- }
80
-
81
- // sanitizeUnicode(text, opts) -> { clean, removed, findings }
82
- // clean - text with the smuggling channels stripped/normalized
83
- // removed - total count of stripped/collapsed characters (0 = nothing hidden)
84
- // findings - per-category counts (only non-zero keys), for transparent reporting
85
- //
86
- // opts.normalize: 'NFC' (default, appearance-preserving) | 'NFKC' (also folds
87
- // fullwidth/homoglyph compatibility forms - stronger for detection, but rewrites
88
- // some visible glyphs) | 'none'.
89
- // opts.collapseCombiningOver: max combining marks kept per run (default 4).
90
- export function sanitizeUnicode(text, { normalize = 'NFC', collapseCombiningOver = 4 } = {}) {
91
- if (typeof text !== 'string' || text === '') return { clean: text ?? '', removed: 0, findings: {} };
92
- let s = text;
93
- const findings = {};
94
-
95
- // Strip one category, counting by code point (spread iterates code points, so a
96
- // supplementary char like a Tag counts as 1, not 2 UTF-16 units).
97
- const strip = (re, key) => {
98
- let n = 0;
99
- s = s.replace(re, (m) => { n += [...m].length; return ''; });
100
- if (n) findings[key] = n;
101
- };
102
-
103
- let lineSep = 0;
104
- s = s.replace(LINE_SEP, () => { lineSep++; return '\n'; });
105
- if (lineSep) findings.lineSep = lineSep;
106
-
107
- strip(TAGS, 'tags');
108
- strip(BIDI, 'bidi');
109
- strip(ZERO_WIDTH, 'zeroWidth');
110
- strip(ANOMALOUS_JOIN_VS, 'joinersVS');
111
-
112
- // Compose canonically so split/decomposed forms can't dodge the detector.
113
- if (normalize && normalize !== 'none') {
114
- try { s = s.normalize(normalize); } catch { /* invalid form name -> skip */ }
115
- }
116
-
117
- let combining = 0;
118
- s = s.replace(COMBINING_RUN, (run) => {
119
- const marks = [...run];
120
- if (marks.length <= collapseCombiningOver) return run;
121
- combining += marks.length - collapseCombiningOver;
122
- return marks.slice(0, collapseCombiningOver).join('');
123
- });
124
- if (combining) findings.combining = combining;
125
-
126
- const removed = (findings.tags || 0) + (findings.bidi || 0) + (findings.zeroWidth || 0)
127
- + (findings.joinersVS || 0) + (findings.combining || 0);
128
- return { clean: s, removed, findings };
129
- }
130
-
131
- // Convenience for the common "just give me clean text" caller.
132
- export function stripHidden(text, opts) {
133
- return sanitizeUnicode(text, opts).clean;
134
- }
135
-
136
- // Exposed for tests / external auditing.
137
- export const SANITIZE_RANGES = RANGES;
3
+ // This file used to be a HAND copy of chatpanel-pii/sanitize.js, kept in step by memory — the
4
+ // exact drift the vendoring scripts exist to prevent. The engine is now vendored properly under
5
+ // src/pii/ (npm run sync:pii, with a --check drift guard), so this is a re-export and there is
6
+ // only one copy in the repo to diverge from. Existing importers are unchanged.
7
+ export { hasHiddenChars, sanitizeUnicode, stripHidden, SANITIZE_RANGES } from './pii/sanitize.js';