@chatpanel/bridge 0.10.41 → 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/api-compat.js +4 -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/cli-errors.js +75 -11
- package/src/engines/claude.js +41 -1
- package/src/engines/codex.js +134 -56
- 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/scopes.js +1 -1
- package/src/events/view.js +96 -0
- package/src/mcp-quarantine.js +110 -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/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';
|
package/src/server.js
CHANGED
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
68
68
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
69
69
|
// this drifts from package.json, so the two can't silently diverge.
|
|
70
|
-
const VERSION = '0.
|
|
70
|
+
const VERSION = '0.11.0';
|
|
71
71
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
72
72
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
73
73
|
|
|
@@ -244,6 +244,57 @@ function ensureToken() {
|
|
|
244
244
|
log('error', `could not initialise bridge token: ${e?.message || e}`);
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// CHANNELS — a messaging surface (Telegram today) driving a local agent.
|
|
249
|
+
//
|
|
250
|
+
// Hosted HERE because a channel has to be running when nobody is looking: the point is to
|
|
251
|
+
// reach your machine from a phone with the browser closed, and the bridge is the only
|
|
252
|
+
// always-on local process a ChatPanel user already has. The alternative — a second daemon, or
|
|
253
|
+
// an npm install, or a service worker Chrome suspends — is another thing a non-technical
|
|
254
|
+
// person has to install and keep alive, which is the same as not shipping it.
|
|
255
|
+
//
|
|
256
|
+
// The bridge owns transport and auth; @chatpanel/channels (vendored to src/channels) owns the
|
|
257
|
+
// contract: verify a bot, hold its token 0600, enroll a phone by one-time code, cap it with
|
|
258
|
+
// `reach`, redact both ways. Loaded lazily so the redaction engine never touches a boot where
|
|
259
|
+
// no channel is configured.
|
|
260
|
+
let channelsSvc = null;
|
|
261
|
+
async function channelService() {
|
|
262
|
+
if (channelsSvc) return channelsSvc;
|
|
263
|
+
const { createChannelService } = await import('./channels/service.js');
|
|
264
|
+
channelsSvc = createChannelService({
|
|
265
|
+
home: join(os.homedir(), '.chatpanel'),
|
|
266
|
+
dataDir: join(os.homedir(), '.chatpanel', 'channels'),
|
|
267
|
+
// It talks to THIS bridge as a privileged local client — same port, same token it just
|
|
268
|
+
// read. No second address to configure and get wrong.
|
|
269
|
+
bridge: { baseUrl: `http://127.0.0.1:${PORT}`, token: AUTH_TOKEN },
|
|
270
|
+
logger: { log: (m) => log('info', m), warn: (m) => log('info', m), error: (m) => log('error', m) },
|
|
271
|
+
});
|
|
272
|
+
return channelsSvc;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function handleChannels(req, res, action) {
|
|
276
|
+
try {
|
|
277
|
+
const svc = await channelService();
|
|
278
|
+
const body = req.method === 'POST' ? await readBody(req) : {};
|
|
279
|
+
if (action === 'status') return json(res, 200, await svc.status());
|
|
280
|
+
if (action === 'connect') {
|
|
281
|
+
// The token is verified with Telegram before it is written, so a typo fails HERE, in the
|
|
282
|
+
// settings screen, with a reason — not later as a silent poll loop nobody reads the logs of.
|
|
283
|
+
const r = await svc.connect(body);
|
|
284
|
+
return json(res, 200, { ok: true, ...r });
|
|
285
|
+
}
|
|
286
|
+
if (action === 'pair') return json(res, 200, await svc.pair());
|
|
287
|
+
if (action === 'unpair') return json(res, 200, await svc.unpair(String(body.actorId || '')));
|
|
288
|
+
if (action === 'settings') return json(res, 200, await svc.update(body));
|
|
289
|
+
if (action === 'disconnect') return json(res, 200, await svc.stop({ forget: !!body.forget }));
|
|
290
|
+
return json(res, 404, { error: 'unknown channel action' });
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// A readable reason, because every one of these is something a person can fix: a bad token,
|
|
293
|
+
// a bot not created yet, no network.
|
|
294
|
+
return json(res, 400, { error: e?.message || String(e) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
247
298
|
function tokenOk(req) {
|
|
248
299
|
if (!AUTH_TOKEN) return false;
|
|
249
300
|
const h = String(req.headers['authorization'] || '');
|
|
@@ -278,8 +329,16 @@ const PRIVILEGED_POST = new Set([
|
|
|
278
329
|
// endpoint that touches a run is already guarded. An unauthenticated hole next to nine
|
|
279
330
|
// guarded neighbours is a hole regardless of how little it grants.
|
|
280
331
|
'/cancel',
|
|
332
|
+
// A channel is a way into this machine from the internet. Everything that configures one —
|
|
333
|
+
// and the code that enrolls a phone — is as privileged as /chat itself.
|
|
334
|
+
'/channels/connect',
|
|
335
|
+
'/channels/pair',
|
|
336
|
+
'/channels/unpair',
|
|
337
|
+
'/channels/settings',
|
|
338
|
+
'/channels/disconnect',
|
|
281
339
|
]);
|
|
282
|
-
|
|
340
|
+
// /channels lists which phones may drive this machine. That is not a public reading.
|
|
341
|
+
const PRIVILEGED_GET = new Set(['/debug', '/channels']);
|
|
283
342
|
|
|
284
343
|
// /skills* is NOT privileged, and that is a considered position rather than a
|
|
285
344
|
// convenience. `privileged` adds exactly one thing over the origin allowlist: it requires
|
|
@@ -1123,6 +1182,10 @@ const server = createServer(async (req, res) => {
|
|
|
1123
1182
|
if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
|
|
1124
1183
|
if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
|
|
1125
1184
|
}
|
|
1185
|
+
if (req.method === 'GET' && url.pathname === '/channels') return handleChannels(req, res, 'status');
|
|
1186
|
+
if (req.method === 'POST' && url.pathname.startsWith('/channels/')) {
|
|
1187
|
+
return handleChannels(req, res, url.pathname.slice('/channels/'.length));
|
|
1188
|
+
}
|
|
1126
1189
|
if (req.method === 'POST' && url.pathname === '/cancel') return handleCancel(req, res);
|
|
1127
1190
|
if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
|
|
1128
1191
|
if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
|
|
@@ -1237,6 +1300,17 @@ function startServer() {
|
|
|
1237
1300
|
log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
|
|
1238
1301
|
}
|
|
1239
1302
|
log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Antigravity) appear automatically.');
|
|
1303
|
+
// A channel someone connected last week must come back by itself after a reboot — nobody
|
|
1304
|
+
// is at the keyboard to press start, which is the entire premise of driving this from a
|
|
1305
|
+
// phone. Nothing loads and nothing runs until a bot has actually been connected.
|
|
1306
|
+
channelService()
|
|
1307
|
+
.then((svc) => svc.startIfConfigured())
|
|
1308
|
+
.then((r) => {
|
|
1309
|
+
if (r?.skipped) return;
|
|
1310
|
+
if (r?.ok) log('info', 'channels: telegram connected — polling for messages');
|
|
1311
|
+
else log('error', `channels: telegram not started — ${r?.error || 'unknown error'}`);
|
|
1312
|
+
})
|
|
1313
|
+
.catch((e) => log('error', `channels: ${e?.message || e}`));
|
|
1240
1314
|
});
|
|
1241
1315
|
}
|
|
1242
1316
|
|