chrome-bridge-mcp 1.20.0 → 1.20.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.
- package/README.md +27 -0
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/protocol.js +1 -1
- package/server/tools.js +27 -4
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ Environment variables, each with a matching CLI flag:
|
|
|
156
156
|
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
157
157
|
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
|
|
158
158
|
| `CHROME_BRIDGE_NO_JS` / `--no-js` | unset | No arbitrary JavaScript in the page: `execute_js` and `modify_dom` leave the schema, `wait_for(condition=function)` and `javascript:`/`data:` URLs are refused. `get_status` reports `js_evaluation` |
|
|
159
|
+
| `CHROME_BRIDGE_READ_ROOT` / `--read-root` | unset | Where `upload_file` may read from. Unset: any file except keys and credentials (`~/.ssh`, `~/.aws`, `~/.gnupg`, `.env*`, `id_*`, `*.pem`, `*.key`, …), symlinks resolved first. Set: only files under this directory, whatever their name — the way to upload a certificate key on purpose |
|
|
159
160
|
| `CHROME_BRIDGE_OBSERVE` | unset | `off` stops the local error log below. The plugin sets `hook`: its hooks keep the log and the server stays out of it |
|
|
160
161
|
| `CHROME_BRIDGE_WRITE_ROOT` / `--write-root` | unset | Every path the model chooses (`save_to`, `output_path`, exports) must be under this directory, checked before the browser does any work; the server's own state under `~/.config/chrome-bridge` stays writable. The CLI is your shell and is not restricted |
|
|
161
162
|
|
|
@@ -172,6 +173,32 @@ exports and screenshots are **not** redacted and may carry cookies, tokens or
|
|
|
172
173
|
personal data. Don't point the automation at pages holding secrets you wouldn't
|
|
173
174
|
paste into a chat.
|
|
174
175
|
|
|
176
|
+
### Threat model
|
|
177
|
+
|
|
178
|
+
chrome-bridge gives an AI agent your real, logged-in browser. Scanners that
|
|
179
|
+
rate MCP servers by capability flag it, correctly, for code execution
|
|
180
|
+
(`execute_js`), requests that carry your cookies (`http_request`) and reading
|
|
181
|
+
private page data (`get_storage`, `get_tabs`). Those are the product, not bugs.
|
|
182
|
+
What matters is who can reach them and what a hostile page can make the agent
|
|
183
|
+
do with them.
|
|
184
|
+
|
|
185
|
+
- **Who can connect:** see above — loopback bind, `chrome-extension://` origin
|
|
186
|
+
check, `CHROME_BRIDGE_TOKEN` on both handshakes.
|
|
187
|
+
- **The real risk is prompt injection.** A page can ask the agent to run
|
|
188
|
+
JavaScript, send a request with your cookies, submit a form or upload a file.
|
|
189
|
+
Treat every page you automate as untrusted input, and review what the agent
|
|
190
|
+
proposes on sites that hold money or credentials.
|
|
191
|
+
- **Reduce the surface:** `--caps core` (the default) exposes the smallest set;
|
|
192
|
+
`--no-js` removes arbitrary JavaScript; `--write-root` fences every path the
|
|
193
|
+
model writes; `upload_file` refuses keys and credentials, and `--read-root`
|
|
194
|
+
fences it to one directory. For sessions that matter, automate in a separate
|
|
195
|
+
Chrome profile.
|
|
196
|
+
- **`--no-js` is not a sandbox:** `inject_css` stays, and CSS attribute
|
|
197
|
+
selectors with `url()` can leak attribute values to a remote host.
|
|
198
|
+
- **What leaves your machine** is what the browser sends on the agent's behalf.
|
|
199
|
+
chrome-bridge itself has no telemetry; the error log below stays local unless
|
|
200
|
+
you approve an issue.
|
|
201
|
+
|
|
175
202
|
### Local error log
|
|
176
203
|
|
|
177
204
|
When one of its own tools fails, chrome-bridge adds a line to a **local** file,
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.20.
|
|
4
|
+
"version": "1.20.1",
|
|
5
5
|
"description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
|
|
6
6
|
"homepage_url": "https://github.com/frsorrentino/chrome-bridge",
|
|
7
7
|
"minimum_chrome_version": "135",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
3
|
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
-
"version": "1.20.
|
|
4
|
+
"version": "1.20.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/index.js
CHANGED
|
@@ -38,6 +38,7 @@ function parseSecurity() {
|
|
|
38
38
|
return {
|
|
39
39
|
noJs: process.argv.includes('--no-js') || on(process.env.CHROME_BRIDGE_NO_JS),
|
|
40
40
|
writeRoot: argValue('--write-root') ?? process.env.CHROME_BRIDGE_WRITE_ROOT ?? null,
|
|
41
|
+
readRoot: argValue('--read-root') ?? process.env.CHROME_BRIDGE_READ_ROOT ?? null,
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
package/server/protocol.js
CHANGED
package/server/tools.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* e restituisce il risultato al client MCP.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { appendFile, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { basename, dirname, extname, join, resolve, sep } from 'node:path';
|
|
11
11
|
import { parse as parseHtml } from 'node-html-parser';
|
|
@@ -40,6 +40,14 @@ const JS_TOOLS = new Set(['execute_js', 'modify_dom']);
|
|
|
40
40
|
// Sotto CHROME_BRIDGE_WRITE_ROOT lo stato del server resta scrivibile: non è un
|
|
41
41
|
// percorso scelto dal modello.
|
|
42
42
|
const STATE_DIRS = [join(homedir(), '.config', 'chrome-bridge'), SESSIONS_DIR, RECORDINGS_DIR, FIXTURES_DIR];
|
|
43
|
+
// upload_file mette un file del disco in un form della pagina: una pagina ostile
|
|
44
|
+
// con prompt injection può chiedere ~/.ssh/id_ed25519 o un .env. Senza
|
|
45
|
+
// CHROME_BRIDGE_READ_ROOT questi percorsi sono rifiutati; con la radice
|
|
46
|
+
// impostata vale solo la radice, scelta a mano da chi la imposta (il caso
|
|
47
|
+
// legittimo: una chiave SSL da caricare nel pannello dell'hosting).
|
|
48
|
+
const SECRET_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.azure', join('.config', 'gcloud'), join('.config', 'gh'), join('.config', 'chrome-bridge')]
|
|
49
|
+
.map((d) => join(homedir(), d));
|
|
50
|
+
const SECRET_FILES = /(^|[\\/])(\.env(\..*)?|\.netrc|\.npmrc|\.pypirc|\.git-credentials|id_(rsa|dsa|ecdsa|ed25519)(_sk)?|[^\\/]*\.(pem|key|p12|pfx|jks|keystore|kdbx))$/i;
|
|
43
51
|
|
|
44
52
|
// `hint` è il parametro REALE del tool chiamante che riduce i dati. Suggerire
|
|
45
53
|
// max_length quando 56 tool su 59 non lo espongono mandava il modello a
|
|
@@ -364,7 +372,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
364
372
|
* @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
|
|
365
373
|
* @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
|
|
366
374
|
* @param {string} [caps='all'] - 'all', 'core', o lista di gruppi "audits,visual"
|
|
367
|
-
* @param {{noJs?: boolean, writeRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT
|
|
375
|
+
* @param {{noJs?: boolean, writeRoot?: string|null, readRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT / CHROME_BRIDGE_READ_ROOT
|
|
368
376
|
*/
|
|
369
377
|
/** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
|
|
370
378
|
function observedProcedure(name, d) {
|
|
@@ -396,6 +404,20 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
396
404
|
if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
|
|
397
405
|
throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
|
|
398
406
|
};
|
|
407
|
+
const READ_ROOT = options.readRoot ? resolve(String(options.readRoot)) : null;
|
|
408
|
+
// realpath: un symlink innocuo verso ~/.ssh non deve passare.
|
|
409
|
+
const guardRead = async (p) => {
|
|
410
|
+
const abs = await realpath(resolve(String(p)));
|
|
411
|
+
const inside = (root) => abs === root || abs.startsWith(root + sep);
|
|
412
|
+
if (READ_ROOT) {
|
|
413
|
+
if (inside(READ_ROOT)) return abs;
|
|
414
|
+
throw new Error(`Refusing to read ${abs}: outside CHROME_BRIDGE_READ_ROOT (${READ_ROOT})`);
|
|
415
|
+
}
|
|
416
|
+
if (SECRET_DIRS.some(inside) || SECRET_FILES.test(abs)) {
|
|
417
|
+
throw new Error(`Refusing to upload ${abs}: looks like a key or credential file. To upload it on purpose, set CHROME_BRIDGE_READ_ROOT to a folder that contains it.`);
|
|
418
|
+
}
|
|
419
|
+
return abs;
|
|
420
|
+
};
|
|
399
421
|
const activeCaps = caps === 'all'
|
|
400
422
|
? ['core', ...Object.keys(TOOL_CAPS)]
|
|
401
423
|
: ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
|
|
@@ -619,6 +641,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
619
641
|
// scoprire che esiste ma è in un gruppo disattivato.
|
|
620
642
|
js_evaluation: !noJs,
|
|
621
643
|
write_root: WRITE_ROOT,
|
|
644
|
+
read_root: READ_ROOT,
|
|
622
645
|
caps_active: activeCaps,
|
|
623
646
|
caps_available: ['core', ...Object.keys(TOOL_CAPS)],
|
|
624
647
|
session_tab_id: sessionTabId,
|
|
@@ -1519,12 +1542,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1519
1542
|
'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
|
|
1520
1543
|
{
|
|
1521
1544
|
selector: z.string().describe('The file input to fill; ">>>" pierces shadow DOM'),
|
|
1522
|
-
path: z.string().describe('Absolute path on the server machine'),
|
|
1545
|
+
path: z.string().describe('Absolute path on the server machine; keys and credentials (~/.ssh, .env, *.pem) refused'),
|
|
1523
1546
|
mime_type: z.string().optional().describe('Default: inferred from extension'),
|
|
1524
1547
|
tab_id: tabId,
|
|
1525
1548
|
},
|
|
1526
1549
|
async ({ selector, path, mime_type, tab_id }) => {
|
|
1527
|
-
const buf = await readFile(path);
|
|
1550
|
+
const buf = await readFile(await guardRead(path));
|
|
1528
1551
|
if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
|
|
1529
1552
|
const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
|
|
1530
1553
|
const data = await send(MessageType.UPLOAD_FILE, {
|