@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
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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';
|