@chatpanel/bridge 0.10.21 → 0.10.23
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 +2 -2
- package/src/engines/antigravity.js +10 -2
- package/src/engines/args.js +47 -0
- package/src/engines/claude.js +32 -21
- package/src/engines/cli-agents.js +160 -8
- package/src/engines/codex.js +10 -2
- package/src/engines/custom.js +82 -75
- package/src/engines/stream-formats.js +207 -0
- package/src/entitlement.js +8 -12
- package/src/env.js +1 -1
- package/src/net.js +107 -0
- package/src/proc.js +21 -0
- package/src/server.js +31 -9
- package/src/ssrf.js +25 -110
package/src/server.js
CHANGED
|
@@ -26,10 +26,11 @@ import { join } from 'node:path';
|
|
|
26
26
|
import * as claude from './engines/claude.js';
|
|
27
27
|
import * as codex from './engines/codex.js';
|
|
28
28
|
import * as antigravity from './engines/antigravity.js';
|
|
29
|
-
import { pi, opencode, kiro } from './engines/cli-agents.js';
|
|
29
|
+
import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
|
|
30
30
|
import * as custom from './engines/custom.js';
|
|
31
31
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
32
32
|
import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
33
|
+
import { stripHidden } from './sanitize.js';
|
|
33
34
|
import { checkForUpdate, selfUpdate } from './update.js';
|
|
34
35
|
import { callLocalMcp } from './mcp-local.js';
|
|
35
36
|
import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
|
|
@@ -37,7 +38,7 @@ import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
|
|
|
37
38
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
38
39
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
39
40
|
// this drifts from package.json, so the two can't silently diverge.
|
|
40
|
-
const VERSION = '0.10.
|
|
41
|
+
const VERSION = '0.10.23';
|
|
41
42
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
42
43
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
43
44
|
|
|
@@ -48,6 +49,8 @@ const ENGINES = {
|
|
|
48
49
|
pi: { engine: pi, label: 'Pi' },
|
|
49
50
|
opencode: { engine: opencode, label: 'OpenCode' },
|
|
50
51
|
kiro: { engine: kiro, label: 'Kiro' },
|
|
52
|
+
copilot: { engine: copilot, label: 'GitHub Copilot' },
|
|
53
|
+
deepseek: { engine: deepseek, label: 'DeepSeek Harness' },
|
|
51
54
|
// "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
|
|
52
55
|
// from /health (it's not a single installable agent; the extension manages the
|
|
53
56
|
// list and validates commands via /agent-check).
|
|
@@ -108,10 +111,13 @@ function relayToolCall(session, name, input) {
|
|
|
108
111
|
|
|
109
112
|
// The extension returns a string OR { text, image(dataURL) }; map to MCP content.
|
|
110
113
|
function toMcpContent(result) {
|
|
114
|
+
// L5: de-steganographize tool-result TEXT before it flows back to the CLI/model —
|
|
115
|
+
// the bridge is a public localhost endpoint, so (like the prompt path) it must strip
|
|
116
|
+
// ASCII-smuggled / bidi Unicode from relayed results, not assume the caller did.
|
|
111
117
|
if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
|
|
112
|
-
if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
|
|
118
|
+
if (typeof result === 'string') return { content: [{ type: 'text', text: stripHidden(result) }] };
|
|
113
119
|
const content = [];
|
|
114
|
-
if (result.text) content.push({ type: 'text', text: String(result.text) });
|
|
120
|
+
if (result.text) content.push({ type: 'text', text: stripHidden(String(result.text)) });
|
|
115
121
|
if (typeof result.image === 'string') {
|
|
116
122
|
const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
|
|
117
123
|
if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
|
|
@@ -330,9 +336,13 @@ async function handleChat(req, res) {
|
|
|
330
336
|
if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
331
337
|
};
|
|
332
338
|
|
|
333
|
-
// If the client disconnects, stop caring about late
|
|
339
|
+
// If the client disconnects (Stop, or the panel closes), stop caring about late
|
|
340
|
+
// writes AND abort the run so the engine kills its CLI child instead of letting it
|
|
341
|
+
// finish in the background. Older engines ignore the signal (harmless); the spawn
|
|
342
|
+
// engines honor it via killOnAbort.
|
|
334
343
|
let closed = false;
|
|
335
|
-
|
|
344
|
+
const ac = new AbortController();
|
|
345
|
+
req.on('close', () => { closed = true; ac.abort(); });
|
|
336
346
|
|
|
337
347
|
const safeEmit = (obj) => { if (!closed) emit(obj); };
|
|
338
348
|
|
|
@@ -358,6 +368,7 @@ async function handleChat(req, res) {
|
|
|
358
368
|
images: Array.isArray(body.images) ? body.images : [],
|
|
359
369
|
},
|
|
360
370
|
safeEmit,
|
|
371
|
+
{ signal: ac.signal },
|
|
361
372
|
);
|
|
362
373
|
} catch (e) {
|
|
363
374
|
log('error', `${body.agent} chat failed: ${e?.message || e}`);
|
|
@@ -718,11 +729,16 @@ const server = createServer(async (req, res) => {
|
|
|
718
729
|
try {
|
|
719
730
|
if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
|
|
720
731
|
if (req.method === 'GET' && url.pathname === '/debug') {
|
|
732
|
+
// L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
|
|
733
|
+
// to diagnose "is codex installed?". The full home dir, $PATH, and resolved
|
|
734
|
+
// binary paths (which embed the username / home) leak environment detail, so
|
|
735
|
+
// they're opt-in behind CHATPANEL_BRIDGE_DEBUG=1. The extension doesn't read
|
|
736
|
+
// this route, so trimming it by default breaks nothing.
|
|
737
|
+
const verbose = /^(1|true|yes|on)$/i.test(process.env.CHATPANEL_BRIDGE_DEBUG || '');
|
|
721
738
|
return json(res, 200, {
|
|
722
739
|
version: VERSION,
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
path: process.env.PATH,
|
|
740
|
+
agents: Object.fromEntries(AGENT_CLIS.map((name) => [name, verbose ? (findAgentBin(name) || null) : !!findAgentBin(name)])),
|
|
741
|
+
...(verbose ? { home: os.homedir(), path: process.env.PATH } : {}),
|
|
726
742
|
});
|
|
727
743
|
}
|
|
728
744
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
@@ -826,6 +842,12 @@ function startServer() {
|
|
|
826
842
|
});
|
|
827
843
|
server.listen(PORT, HOST, async () => {
|
|
828
844
|
log('info', `listening on http://${HOST}:${PORT}`);
|
|
845
|
+
// M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed
|
|
846
|
+
// returns true for any Host), so the only inbound guard left is the per-install
|
|
847
|
+
// token / extension Origin. Make that trade-off LOUD — it's rarely what you want.
|
|
848
|
+
if (!LOOPBACK_HOSTNAMES.has(HOST)) {
|
|
849
|
+
log('error', `⚠ SECURITY: bound to NON-LOOPBACK host ${HOST}. The anti-rebinding Host check is OFF, so any device that reaches this port (and any web page via a spoofed Host header) can drive local agents — gated only by the bridge token. Only do this on a trusted, firewalled network; prefer 127.0.0.1.`);
|
|
850
|
+
}
|
|
829
851
|
for (const [, { engine, label, hidden }] of Object.entries(ENGINES)) {
|
|
830
852
|
if (hidden) continue;
|
|
831
853
|
const a = await engine.available().catch(() => ({ ok: false }));
|
package/src/ssrf.js
CHANGED
|
@@ -1,88 +1,35 @@
|
|
|
1
|
-
// SSRF guard for the /mcp-remote
|
|
1
|
+
// SSRF guard for the bridge's outbound proxies (/mcp-remote, /fetch-title).
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// cloud metadata or use to sweep the LAN.
|
|
3
|
+
// The host CLASSIFICATION (what is loopback / cloud-metadata / RFC1918 / CGNAT /
|
|
4
|
+
// ULA / link-local / .local) now lives in ONE shared place — src/net.js, a vendored
|
|
5
|
+
// copy of @chatpanel/pii/net.js, the same classifier the gateway and extension use.
|
|
6
|
+
// This file keeps only the bridge's two POLICIES + their exact error messages, so a
|
|
7
|
+
// security guard can't drift between the direct client path and the proxied path.
|
|
8
|
+
// See docs/secure-data-plane.md.
|
|
10
9
|
//
|
|
11
|
-
//
|
|
12
|
-
// •
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// when private hosts are opted in.
|
|
20
|
-
// • Everything else private/internal (RFC1918, CGNAT, link-local, IPv6 ULA,
|
|
21
|
-
// 0.0.0.0, ::, *.local) → BLOCKED, unless the operator opts in with
|
|
22
|
-
// CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1 (for reaching an MCP server on
|
|
23
|
-
// another machine on a trusted LAN).
|
|
24
|
-
// • Non-http(s) schemes → BLOCKED.
|
|
10
|
+
// Two policies:
|
|
11
|
+
// • /mcp-remote (assertPublicHttpUrl): loopback ALLOWED (the user's own MCP
|
|
12
|
+
// servers — the whole point of "via bridge"), cloud metadata ALWAYS blocked,
|
|
13
|
+
// every other private/LAN range blocked UNLESS the operator opts in with
|
|
14
|
+
// CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1 (reaching an MCP server on a trusted LAN).
|
|
15
|
+
// • /fetch-title (assertPublicWebUrl): STRICTER — a page fetch has no business
|
|
16
|
+
// touching loopback OR any private host, so loopback + metadata + every private
|
|
17
|
+
// range are blocked unconditionally (the opt-in is deliberately NOT honored).
|
|
25
18
|
//
|
|
26
|
-
//
|
|
19
|
+
// Non-http(s) schemes are blocked in both. Run the assert on the initial URL AND
|
|
20
|
+
// after every redirect hop.
|
|
21
|
+
|
|
22
|
+
import { isLoopbackHost, isBlockedHost } from './net.js';
|
|
23
|
+
|
|
24
|
+
export { isLoopbackHost };
|
|
27
25
|
|
|
28
26
|
const ALLOW_PRIVATE_HOSTS = /^(1|true|yes|on)$/i.test(
|
|
29
27
|
process.env.CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS || '',
|
|
30
28
|
);
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
34
|
-
if (!m) return null;
|
|
35
|
-
const o = m.slice(1).map(Number);
|
|
36
|
-
if (o.some((n) => n > 255)) return null;
|
|
37
|
-
return o;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Loopback = this host's own services. Reachable by the extension directly, so
|
|
41
|
-
// allowing the bridge to reach it adds no capability.
|
|
42
|
-
export function isLoopbackHost(hostname) {
|
|
43
|
-
const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
44
|
-
if (!h) return false;
|
|
45
|
-
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
46
|
-
if (h === '::1') return true;
|
|
47
|
-
const o = ipv4(h);
|
|
48
|
-
return !!(o && o[0] === 127);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Cloud instance metadata — credential-theft vector. Always blocked.
|
|
52
|
-
function isMetadataHost(hostname) {
|
|
53
|
-
const o = ipv4(hostname);
|
|
54
|
-
return !!(o && o[0] === 169 && o[1] === 254);
|
|
55
|
-
}
|
|
56
|
-
|
|
30
|
+
// MCP-proxy policy: loopback ok, metadata never, other private only when opted in.
|
|
57
31
|
export function isBlockedHttpHost(hostname) {
|
|
58
|
-
|
|
59
|
-
if (!h) return true;
|
|
60
|
-
if (isLoopbackHost(h)) return false; // user's own host — allowed
|
|
61
|
-
if (isMetadataHost(h)) return true; // never proxy cloud metadata, even when private is opted in
|
|
62
|
-
if (ALLOW_PRIVATE_HOSTS) return false; // operator opted in to LAN/private targets
|
|
63
|
-
|
|
64
|
-
// Default deny for the rest of the private/internal space.
|
|
65
|
-
if (h.endsWith('.local')) return true; // mDNS / LAN
|
|
66
|
-
if (
|
|
67
|
-
h === '::' ||
|
|
68
|
-
h.startsWith('fc') ||
|
|
69
|
-
h.startsWith('fd') || // IPv6 ULA
|
|
70
|
-
h.startsWith('fe8') ||
|
|
71
|
-
h.startsWith('fe9') ||
|
|
72
|
-
h.startsWith('fea') ||
|
|
73
|
-
h.startsWith('feb') // IPv6 link-local
|
|
74
|
-
) {
|
|
75
|
-
return true;
|
|
76
|
-
}
|
|
77
|
-
const o = ipv4(h);
|
|
78
|
-
if (o) {
|
|
79
|
-
const [a, b] = o;
|
|
80
|
-
if (a === 0 || a === 10) return true; // this-host / RFC1918
|
|
81
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
82
|
-
if (a === 192 && b === 168) return true; // RFC1918
|
|
83
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
32
|
+
return isBlockedHost(hostname, { allowLoopback: true, allowPrivate: ALLOW_PRIVATE_HOSTS });
|
|
86
33
|
}
|
|
87
34
|
|
|
88
35
|
export function assertPublicHttpUrl(u) {
|
|
@@ -101,41 +48,9 @@ export function assertPublicHttpUrl(u) {
|
|
|
101
48
|
return parsed;
|
|
102
49
|
}
|
|
103
50
|
|
|
104
|
-
//
|
|
105
|
-
// a page fetch has NO legitimate reason to reach the user's own loopback services or any LAN /
|
|
106
|
-
// private host — those would be pure SSRF (port-scan the LAN, hit a localhost admin panel, read
|
|
107
|
-
// cloud metadata). So loopback + metadata + every private/internal range are blocked
|
|
108
|
-
// UNCONDITIONALLY here; the CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS opt-in (meant for reaching a LAN
|
|
109
|
-
// MCP server, a different trust context) is deliberately NOT honored. Only genuinely public
|
|
110
|
-
// http(s) hosts pass. Re-run this on every redirect hop, not just the initial URL.
|
|
51
|
+
// Web-fetch policy (stricter): block loopback + metadata + all private, always.
|
|
111
52
|
export function isDisallowedWebHost(hostname) {
|
|
112
|
-
|
|
113
|
-
if (!h) return true;
|
|
114
|
-
if (isLoopbackHost(h)) return true; // localhost/127.x — a page fetch must never touch it
|
|
115
|
-
if (isMetadataHost(h)) return true; // 169.254.169.254 — credential theft
|
|
116
|
-
if (h.endsWith('.local')) return true; // mDNS / LAN
|
|
117
|
-
if (
|
|
118
|
-
h === '::' ||
|
|
119
|
-
h.startsWith('fc') ||
|
|
120
|
-
h.startsWith('fd') || // IPv6 ULA
|
|
121
|
-
h.startsWith('fe8') ||
|
|
122
|
-
h.startsWith('fe9') ||
|
|
123
|
-
h.startsWith('fea') ||
|
|
124
|
-
h.startsWith('feb') // IPv6 link-local
|
|
125
|
-
) {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
const o = ipv4(h);
|
|
129
|
-
if (o) {
|
|
130
|
-
const [a, b] = o;
|
|
131
|
-
if (a === 0 || a === 10) return true; // this-host / RFC1918
|
|
132
|
-
if (a === 127) return true; // loopback (also caught above; explicit for clarity)
|
|
133
|
-
if (a === 169 && b === 254) return true; // link-local (incl. metadata)
|
|
134
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
135
|
-
if (a === 192 && b === 168) return true; // RFC1918
|
|
136
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
137
|
-
}
|
|
138
|
-
return false;
|
|
53
|
+
return isBlockedHost(hostname, { allowLoopback: false, allowPrivate: false });
|
|
139
54
|
}
|
|
140
55
|
|
|
141
56
|
export function assertPublicWebUrl(u) {
|