@chatpanel/bridge 0.10.15 → 0.10.17
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 +1 -1
- package/src/engines/antigravity.js +5 -67
- package/src/engines/prompt.js +8 -1
- package/src/sanitize.js +137 -0
- package/src/server.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// path so the model opens it.
|
|
12
12
|
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
-
import { mkdirSync, writeFileSync, unlinkSync
|
|
14
|
+
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { findAgentBin } from '../env.js';
|
|
@@ -78,57 +78,6 @@ function writeImages(images, dir) {
|
|
|
78
78
|
return files;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Antigravity has no per-run MCP flag (`agy --help` shows none). The CLI only
|
|
82
|
-
// discovers MCP servers from config files: <cwd>/.agents/mcp_config.json
|
|
83
|
-
// (workspace) or ~/.gemini/config/mcp_config.json (global). To give a headless
|
|
84
|
-
// `agy -p` run our page/MCP tools, write the workspace file pointing at the
|
|
85
|
-
// bridge's per-session HTTP MCP server. Remote servers MUST use `serverUrl`
|
|
86
|
-
// (Antigravity rejects the legacy `url`/`httpUrl` fields). This is the agy
|
|
87
|
-
// equivalent of what claude.js/codex.js do via --mcp-config / -c mcp_servers.
|
|
88
|
-
//
|
|
89
|
-
// Non-destructive: an existing .agents/mcp_config.json is parsed, our one server
|
|
90
|
-
// merged in, and the original restored on cleanup. If the file exists but isn't
|
|
91
|
-
// JSON we recognize, we leave it untouched (and tools simply won't attach) rather
|
|
92
|
-
// than risk corrupting the user's config. Returns a cleanup fn (always callable).
|
|
93
|
-
function setupMcpConfig(mcp, cwd) {
|
|
94
|
-
if (!mcp?.url) return () => {};
|
|
95
|
-
const dir = path.join(cwd, '.agents');
|
|
96
|
-
const file = path.join(dir, 'mcp_config.json');
|
|
97
|
-
const serverName = mcp.serverName || 'chatpanel_browser';
|
|
98
|
-
const hadDir = existsSync(dir);
|
|
99
|
-
let prev = null; // original file contents (null = file did not exist)
|
|
100
|
-
|
|
101
|
-
let config = { mcpServers: {} };
|
|
102
|
-
if (existsSync(file)) {
|
|
103
|
-
try {
|
|
104
|
-
prev = readFileSync(file, 'utf8');
|
|
105
|
-
const parsed = JSON.parse(prev);
|
|
106
|
-
if (!parsed || typeof parsed !== 'object') return () => {};
|
|
107
|
-
config = parsed;
|
|
108
|
-
if (!config.mcpServers || typeof config.mcpServers !== 'object') config.mcpServers = {};
|
|
109
|
-
} catch {
|
|
110
|
-
return () => {}; // unreadable / not JSON — don't clobber it
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
config.mcpServers[serverName] = { serverUrl: mcp.url };
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
mkdirSync(dir, { recursive: true });
|
|
117
|
-
writeFileSync(file, JSON.stringify(config, null, 2));
|
|
118
|
-
} catch {
|
|
119
|
-
return () => {};
|
|
120
|
-
}
|
|
121
|
-
return () => {
|
|
122
|
-
try {
|
|
123
|
-
if (prev !== null) writeFileSync(file, prev); // restore original contents
|
|
124
|
-
else if (hadDir) rmSync(file, { force: true }); // remove just the file we added
|
|
125
|
-
else rmSync(dir, { recursive: true, force: true }); // remove the dir we created
|
|
126
|
-
} catch {
|
|
127
|
-
/* best effort */
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
81
|
export async function chat({ messages, system, options, images }, emit) {
|
|
133
82
|
try {
|
|
134
83
|
mkdirSync(SCRATCH, { recursive: true });
|
|
@@ -141,29 +90,18 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
141
90
|
// reads @-referenced files (incl. images) inline as multimodal input, so no
|
|
142
91
|
// read-tool approval is needed in headless `-p` mode. (Confirmed working.)
|
|
143
92
|
const imageFiles = writeImages(images, cwd);
|
|
144
|
-
|
|
145
|
-
// per-run MCP flag). Tools then route: agy → bridge /mcp/<session> → extension.
|
|
146
|
-
const cleanupMcp = setupMcpConfig(options.mcp, cwd);
|
|
147
|
-
const cleanup = () => {
|
|
148
|
-
imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
|
|
149
|
-
cleanupMcp();
|
|
150
|
-
};
|
|
93
|
+
const cleanup = () => imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
|
|
151
94
|
let prompt = buildCliPrompt(messages, system);
|
|
152
95
|
if (imageFiles.length) {
|
|
153
96
|
prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
|
|
154
97
|
}
|
|
155
98
|
|
|
156
99
|
// `-p` runs one prompt non-interactively. --model picks the model.
|
|
100
|
+
// --dangerously-skip-permissions auto-approves tool use (headless has no human
|
|
101
|
+
// approver) only when the user opted into bypassPermissions.
|
|
157
102
|
const args = ['-p', prompt];
|
|
158
103
|
if (options.model) args.push('--model', options.model);
|
|
159
|
-
|
|
160
|
-
// tool call just times out (it never reaches the server). So skip agy's own
|
|
161
|
-
// approval whenever the user opted into bypass OR we've attached page/MCP tools.
|
|
162
|
-
// The real gate stays on the ChatPanel side: each relayed call goes back to the
|
|
163
|
-
// extension, which applies its per-action confirmation for risky page actions.
|
|
164
|
-
if (options.permissionMode === 'bypassPermissions' || options.mcp?.url) {
|
|
165
|
-
args.push('--dangerously-skip-permissions');
|
|
166
|
-
}
|
|
104
|
+
if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
|
|
167
105
|
if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
|
|
168
106
|
|
|
169
107
|
await new Promise((resolve, reject) => {
|
package/src/engines/prompt.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { stripHidden } from '../sanitize.js';
|
|
2
|
+
|
|
1
3
|
function roleLabel(role) {
|
|
2
4
|
return role === 'assistant' ? 'Assistant' : 'User';
|
|
3
5
|
}
|
|
@@ -24,5 +26,10 @@ export function buildCliPrompt(messages = [], system = '') {
|
|
|
24
26
|
);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
// De-steganography on the final prompt before it reaches the local agent. The
|
|
30
|
+
// extension already scrubs when its redaction is on, but the bridge is also a
|
|
31
|
+
// public localhost endpoint other clients can call — so strip invisible/format
|
|
32
|
+
// Unicode here too (hidden instructions via Tag chars, zero-width-split values,
|
|
33
|
+
// injected fingerprint markers). See src/sanitize.js.
|
|
34
|
+
return stripHidden(parts.join('\n\n---\n\n'));
|
|
28
35
|
}
|
package/src/sanitize.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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.
|
|
6
|
+
//
|
|
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;
|
package/src/server.js
CHANGED
|
@@ -37,7 +37,7 @@ import { assertPublicHttpUrl } from './ssrf.js';
|
|
|
37
37
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
38
38
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
39
39
|
// this drifts from package.json, so the two can't silently diverge.
|
|
40
|
-
const VERSION = '0.10.
|
|
40
|
+
const VERSION = '0.10.17';
|
|
41
41
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
42
42
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
43
43
|
|