chrome-bridge-mcp 1.20.0 → 1.21.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/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Chrome Bridge
2
2
 
3
+ > By [frsorrentino](https://github.com/frsorrentino) · npm `chrome-bridge-mcp` · not affiliated with other projects named "chrome-bridge".
4
+
3
5
  ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-257%20unit%20%2B%2032%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
4
6
 
5
7
  **An MCP server that gives Claude Code your real, logged-in Chrome — measured
6
8
  2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
7
9
  extension on a form-filling task, with ~3× the toolset and no paid plan.**
8
10
 
9
- 59 web-development tools (navigation, DOM inspection, visual regression, audits,
11
+ 60 web-development tools (navigation, DOM inspection, visual regression, audits,
10
12
  network mocking) over a local WebSocket bridge, plus a headless instance for CI.
11
13
  Self-hosted, local-only.
12
14
 
@@ -53,7 +55,7 @@ register the same server twice.
53
55
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
54
56
  |---|---|---|---|---|
55
57
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
56
- | **Tools** | **59** (38 core) | 22 | 29 default (56 with flags) | 24 core (71 total) |
58
+ | **Tools** | **60** (39 core) | 22 | 29 default (56 with flags) | 24 core (71 total) |
57
59
  | **Requires paid plan** | **No** | Yes (Pro+) | No | No |
58
60
  | **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
59
61
  | **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
@@ -115,16 +117,16 @@ user-script toggle isn't available.
115
117
 
116
118
  ## Tools
117
119
 
118
- 59 in total, in seven groups. Only `core` (38 tools) loads by default; the rest
120
+ 60 in total, in seven groups. Only `core` (39 tools) loads by default; the rest
119
121
  are opt-in via `--caps`.
120
122
 
121
- ![59 tools in seven groups, from clicking a button to auditing a whole page](assets/readme/card4-toolbox.png)
123
+ ![60 tools in seven groups, from clicking a button to auditing a whole page](assets/readme/card4-toolbox.png)
122
124
 
123
125
  | Group | N | What's in it |
124
126
  |---|---|---|
125
127
  | Core & Navigation | 13 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
126
128
  | Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
127
- | DOM & Inspection | 10 | `read_page`, `extract`, `query_dom`, `watch_dom` |
129
+ | DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `get_css_styles`, `watch_dom` |
128
130
  | Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
129
131
  | Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
130
132
  | Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
@@ -156,6 +158,7 @@ Environment variables, each with a matching CLI flag:
156
158
  | `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
157
159
  | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
158
160
  | `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` |
161
+ | `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
162
  | `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
163
  | `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
164
 
@@ -172,6 +175,32 @@ exports and screenshots are **not** redacted and may carry cookies, tokens or
172
175
  personal data. Don't point the automation at pages holding secrets you wouldn't
173
176
  paste into a chat.
174
177
 
178
+ ### Threat model
179
+
180
+ chrome-bridge gives an AI agent your real, logged-in browser. Scanners that
181
+ rate MCP servers by capability flag it, correctly, for code execution
182
+ (`execute_js`), requests that carry your cookies (`http_request`) and reading
183
+ private page data (`get_storage`, `get_tabs`). Those are the product, not bugs.
184
+ What matters is who can reach them and what a hostile page can make the agent
185
+ do with them.
186
+
187
+ - **Who can connect:** see above — loopback bind, `chrome-extension://` origin
188
+ check, `CHROME_BRIDGE_TOKEN` on both handshakes.
189
+ - **The real risk is prompt injection.** A page can ask the agent to run
190
+ JavaScript, send a request with your cookies, submit a form or upload a file.
191
+ Treat every page you automate as untrusted input, and review what the agent
192
+ proposes on sites that hold money or credentials.
193
+ - **Reduce the surface:** `--caps core` (the default) exposes the smallest set;
194
+ `--no-js` removes arbitrary JavaScript; `--write-root` fences every path the
195
+ model writes; `upload_file` refuses keys and credentials, and `--read-root`
196
+ fences it to one directory. For sessions that matter, automate in a separate
197
+ Chrome profile.
198
+ - **`--no-js` is not a sandbox:** `inject_css` stays, and CSS attribute
199
+ selectors with `url()` can leak attribute values to a remote host.
200
+ - **What leaves your machine** is what the browser sends on the agent's behalf.
201
+ chrome-bridge itself has no telemetry; the error log below stays local unless
202
+ you approve an issue.
203
+
175
204
  ### Local error log
176
205
 
177
206
  When one of its own tools fails, chrome-bridge adds a line to a **local** file,
@@ -217,7 +246,7 @@ count.
217
246
 
218
247
  ## Documentation
219
248
 
220
- - [docs/TOOLS.md](docs/TOOLS.md) — all 59 tools, by group
249
+ - [docs/TOOLS.md](docs/TOOLS.md) — all 60 tools, by group
221
250
  - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
222
251
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
223
252
  - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
@@ -0,0 +1,405 @@
1
+ /**
2
+ * cssStylesOf: la cascata CSS di un elemento come la mostra il pannello Styles
3
+ * di DevTools — per ogni proprietà la dichiarazione che vince (selettore,
4
+ * foglio, posizione della regola, !important, @layer, @media) e quelle che ha
5
+ * battuto. `query_dom` dice quanto vale `margin-top`; questo dice chi lo ha
6
+ * deciso e da dove.
7
+ *
8
+ * Senza chrome.debugger non c'è CSS.getMatchedStylesForNode: si ricostruisce
9
+ * dal CSSOM. Ordine (CSS Cascade 5, origine autore): !important, stile inline,
10
+ * @layer (normali: senza layer > ultimo layer dichiarato > primo; important:
11
+ * inverso), specificità, ordine nel sorgente. I longhand sono l'unità della
12
+ * cascata: `margin: 0` compare come margin-top/right/bottom/left.
13
+ *
14
+ * Fuori portata, e detto nell'output o nella descrizione del tool: gli stili
15
+ * dello user agent (non esposti), @container, @scope e @starting-style (non
16
+ * valutabili senza il motore: contati in `skipped`), le regole :host del
17
+ * shadow root di un host, transizioni e animazioni. I fogli di altra origine
18
+ * senza CORS non espongono cssRules: elencati in `opaque_stylesheets`.
19
+ *
20
+ * Non è un modulo ES (vedi element-label.js): get_css_styles inietta prima
21
+ * questo file e poi usa globalThis.__cbCssStyles. In Node i test passano un
22
+ * CSSOM finto come terzo argomento.
23
+ */
24
+ (() => {
25
+ // --- selettori: liste, parentesi, specificità ---
26
+
27
+ const isIdentChar = (ch) => /[\w-]/.test(ch) || ch.charCodeAt(0) > 127;
28
+ const skipIdent = (s, i) => {
29
+ while (i < s.length) {
30
+ if (s[i] === '\\') { i += 2; continue; }
31
+ if (!isIdentChar(s[i])) break;
32
+ i += 1;
33
+ }
34
+ return i;
35
+ };
36
+ const skipString = (s, i) => {
37
+ const q = s[i];
38
+ i += 1;
39
+ while (i < s.length && s[i] !== q) i += s[i] === '\\' ? 2 : 1;
40
+ return i + 1;
41
+ };
42
+ const matchParen = (s, i) => {
43
+ let depth = 0;
44
+ for (; i < s.length; i += 1) {
45
+ const ch = s[i];
46
+ if (ch === '"' || ch === "'") { i = skipString(s, i) - 1; continue; }
47
+ if (ch === '(') depth += 1;
48
+ else if (ch === ')') { depth -= 1; if (depth === 0) return i; }
49
+ }
50
+ return s.length;
51
+ };
52
+ /** Una lista di selettori spezzata sulle virgole di primo livello. */
53
+ const splitList = (s) => {
54
+ const out = [];
55
+ let depth = 0;
56
+ let start = 0;
57
+ for (let i = 0; i < s.length; i += 1) {
58
+ const ch = s[i];
59
+ if (ch === '"' || ch === "'") { i = skipString(s, i) - 1; continue; }
60
+ if (ch === '(' || ch === '[') depth += 1;
61
+ else if (ch === ')' || ch === ']') depth -= 1;
62
+ else if (ch === ',' && depth === 0) { out.push(s.slice(start, i).trim()); start = i + 1; }
63
+ }
64
+ out.push(s.slice(start).trim());
65
+ return out.filter(Boolean);
66
+ };
67
+
68
+ const LEGACY_PSEUDO_ELEMENTS = new Set(['before', 'after', 'first-line', 'first-letter']);
69
+ const ARG_SPECIFICITY = new Set(['is', 'not', 'has', 'matches', 'any', '-webkit-any', '-moz-any']);
70
+ const cmpSpec = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
71
+ const add = (t, u) => { t[0] += u[0]; t[1] += u[1]; t[2] += u[2]; };
72
+ const maxSpec = (list) => list.map(specificity).reduce((m, x) => (cmpSpec(x, m) > 0 ? x : m), [0, 0, 0]);
73
+
74
+ /** Specificità (id, classi/attributi/pseudo-classi, tipi/pseudo-elementi) di un selettore complesso. */
75
+ function specificity(sel) {
76
+ const t = [0, 0, 0];
77
+ const s = String(sel);
78
+ let i = 0;
79
+ while (i < s.length) {
80
+ const ch = s[i];
81
+ if (ch === '"' || ch === "'") { i = skipString(s, i); continue; }
82
+ if (ch === '#') { t[0] += 1; i = skipIdent(s, i + 1); continue; }
83
+ if (ch === '.') { t[1] += 1; i = skipIdent(s, i + 1); continue; }
84
+ if (ch === '[') {
85
+ t[1] += 1;
86
+ const close = s.indexOf(']', i);
87
+ i = close === -1 ? s.length : close + 1;
88
+ continue;
89
+ }
90
+ if (ch === ':') {
91
+ const pseudoElement = s[i + 1] === ':';
92
+ const start = i + (pseudoElement ? 2 : 1);
93
+ const end = skipIdent(s, start);
94
+ const name = s.slice(start, end).toLowerCase();
95
+ i = end;
96
+ let inner = null;
97
+ if (s[i] === '(') { const close = matchParen(s, i); inner = s.slice(i + 1, close); i = close + 1; }
98
+ if (pseudoElement || LEGACY_PSEUDO_ELEMENTS.has(name)) { t[2] += 1; continue; }
99
+ if (name === 'where') continue;
100
+ if (ARG_SPECIFICITY.has(name)) { if (inner != null) add(t, maxSpec(splitList(inner))); continue; }
101
+ t[1] += 1;
102
+ if (inner != null) {
103
+ if (name === 'host' || name === 'host-context') add(t, maxSpec(splitList(inner)));
104
+ else if (name === 'nth-child' || name === 'nth-last-child') {
105
+ const of = /\bof\s+(.+)$/i.exec(inner);
106
+ if (of) add(t, maxSpec(splitList(of[1])));
107
+ }
108
+ }
109
+ continue;
110
+ }
111
+ if (ch === '*') {
112
+ i += 1;
113
+ if (s[i] === '|') { i += 1; if (s[i] === '*') i += 1; else { t[2] += 1; i = skipIdent(s, i); } }
114
+ continue;
115
+ }
116
+ if (isIdentChar(ch) || ch === '\\') {
117
+ i = skipIdent(s, i);
118
+ if (s[i] === '|' && s[i + 1] !== '=') { // ns|tag: conta il tipo, non il namespace
119
+ i += 1;
120
+ if (s[i] === '*') { i += 1; continue; }
121
+ i = skipIdent(s, i);
122
+ }
123
+ t[2] += 1;
124
+ continue;
125
+ }
126
+ i += 1; // combinatori, spazi, & già risolto
127
+ }
128
+ return t;
129
+ }
130
+
131
+ // --- cascata ---
132
+
133
+ const INHERITED = new Set([
134
+ 'border-collapse', 'border-spacing', 'caption-side', 'color', 'color-scheme', 'cursor', 'direction',
135
+ 'empty-cells', 'font', 'font-family', 'font-feature-settings', 'font-kerning', 'font-optical-sizing',
136
+ 'font-palette', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-synthesis',
137
+ 'font-variant', 'font-variant-caps', 'font-variant-east-asian', 'font-variant-ligatures',
138
+ 'font-variant-numeric', 'font-variant-position', 'font-variation-settings', 'font-weight',
139
+ 'hanging-punctuation', 'hyphens', 'image-rendering', 'letter-spacing', 'line-break', 'line-height',
140
+ 'list-style', 'list-style-image', 'list-style-position', 'list-style-type', 'orphans', 'overflow-wrap',
141
+ 'paint-order', 'pointer-events', 'quotes', 'ruby-align', 'ruby-position', 'tab-size', 'text-align',
142
+ 'text-align-last', 'text-combine-upright', 'text-decoration-skip-ink', 'text-emphasis',
143
+ 'text-emphasis-color', 'text-emphasis-position', 'text-emphasis-style', 'text-indent', 'text-justify',
144
+ 'text-orientation', 'text-rendering', 'text-shadow', 'text-size-adjust', 'text-transform',
145
+ 'text-underline-offset', 'text-underline-position', 'text-wrap', 'text-wrap-mode', 'text-wrap-style',
146
+ 'visibility', 'white-space', 'white-space-collapse', 'widows', 'word-break', 'word-spacing', 'word-wrap',
147
+ 'writing-mode', 'accent-color', 'caret-color', 'scrollbar-color', 'print-color-adjust',
148
+ 'forced-color-adjust', 'math-depth', 'math-style', '-webkit-text-fill-color', '-webkit-text-stroke',
149
+ '-webkit-text-stroke-color', '-webkit-text-stroke-width',
150
+ ]);
151
+ const inherits = (p) => p.startsWith('--') || INHERITED.has(p);
152
+ const MAX_OVERRIDDEN = 5;
153
+ const MAX_ANCESTORS = 40;
154
+
155
+ const kindOf = (rule) => rule?.constructor?.name ?? '';
156
+ const sheetLabel = (href, ownerNode, n) => {
157
+ if (href) return href.split('#')[0].split('?')[0].split('/').pop() || href;
158
+ if (ownerNode?.id) return `<style>#${ownerNode.id}`;
159
+ return ownerNode ? `<style> #${n}` : `adopted #${n}`;
160
+ };
161
+ const describe = (el) => {
162
+ const raw = typeof el.className === 'string' ? el.className : (el.getAttribute?.('class') ?? '');
163
+ const classes = raw.trim().split(/\s+/).filter(Boolean).slice(0, 2);
164
+ return (el.tagName || '').toLowerCase() + (el.id ? `#${el.id}` : '') + classes.map((c) => `.${c}`).join('');
165
+ };
166
+ const safeMatches = (el, sel) => { try { return el.matches(sel); } catch { return false; } };
167
+ const resolveNested = (sel, parents) => {
168
+ if (!parents) return sel;
169
+ const parent = `:is(${parents.join(', ')})`;
170
+ return sel.includes('&') ? sel.split('&').join(parent) : `${parent} ${sel}`;
171
+ };
172
+
173
+ /** Le regole di stile di una radice (document o shadow root), in ordine di cascata, con layer e condizioni. */
174
+ function collect(root, env) {
175
+ const sheets = [];
176
+ const opaque = [];
177
+ const skipped = [];
178
+ const rules = [];
179
+ const layerOrder = new Map();
180
+ const layerCounters = new Map();
181
+ let anonymous = 0;
182
+ let unnamed = 0;
183
+ let order = 0;
184
+ const registerLayer = (path) => {
185
+ for (let i = 1; i <= path.length; i += 1) {
186
+ const key = path.slice(0, i).join('.');
187
+ if (layerOrder.has(key)) continue;
188
+ const parent = path.slice(0, i - 1).join('.');
189
+ const n = layerCounters.get(parent) ?? 0;
190
+ layerCounters.set(parent, n + 1);
191
+ layerOrder.set(key, n);
192
+ }
193
+ };
194
+ const layerPath = (base, name) => {
195
+ const path = (base ?? []).concat(name === '' ? [`(anonymous ${(anonymous += 1)})`] : name.split('.'));
196
+ registerLayer(path);
197
+ return path;
198
+ };
199
+ const rankOf = (path) => path && path.map((_, i) => layerOrder.get(path.slice(0, i + 1).join('.')));
200
+ const countRules = (list) => {
201
+ let n = 0;
202
+ for (const r of list ?? []) { n += 1; if (r.cssRules) n += countRules(r.cssRules); }
203
+ return n;
204
+ };
205
+
206
+ const walk = (list, ctx) => {
207
+ for (const rule of list) {
208
+ const kind = kindOf(rule);
209
+ if (kind === 'CSSKeyframesRule' || kind === 'CSSPageRule') continue;
210
+ if (rule.selectorText != null && rule.style) {
211
+ const index = ctx.sheet.rules;
212
+ ctx.sheet.rules += 1;
213
+ const selectors = splitList(rule.selectorText).map((s) => resolveNested(s, ctx.parentSelectors));
214
+ rules.push({ selectors, sheet: ctx.sheet.label, index, layer: ctx.layer, conditions: ctx.conditions, style: rule.style, order: (order += 1) });
215
+ if (rule.cssRules?.length) walk(rule.cssRules, { ...ctx, parentSelectors: selectors, parentIndex: index });
216
+ continue;
217
+ }
218
+ if (kind === 'CSSNestedDeclarations' && rule.style && ctx.parentSelectors) {
219
+ rules.push({ selectors: ctx.parentSelectors, sheet: ctx.sheet.label, index: ctx.parentIndex, layer: ctx.layer, conditions: ctx.conditions, style: rule.style, order: (order += 1) });
220
+ continue;
221
+ }
222
+ if (kind === 'CSSImportRule') {
223
+ const mediaText = rule.media?.mediaText ?? '';
224
+ if (mediaText && !env.matchMedia(mediaText)) continue;
225
+ if (rule.supportsText && !env.supports(rule.supportsText)) continue;
226
+ const layer = rule.layerName == null ? ctx.layer : layerPath(ctx.layer, rule.layerName);
227
+ addSheet(rule.styleSheet, rule.href, { ...ctx, layer });
228
+ continue;
229
+ }
230
+ if (kind === 'CSSLayerStatementRule') {
231
+ for (const name of rule.nameList ?? []) layerPath(ctx.layer, name);
232
+ continue;
233
+ }
234
+ if (kind === 'CSSLayerBlockRule') {
235
+ walk(rule.cssRules, { ...ctx, layer: layerPath(ctx.layer, rule.name ?? '') });
236
+ continue;
237
+ }
238
+ if (kind === 'CSSMediaRule') {
239
+ const text = rule.conditionText ?? rule.media?.mediaText ?? '';
240
+ if (!env.matchMedia(text)) continue;
241
+ walk(rule.cssRules, { ...ctx, conditions: ctx.conditions.concat(`@media ${text}`) });
242
+ continue;
243
+ }
244
+ if (kind === 'CSSSupportsRule') {
245
+ if (!env.supports(rule.conditionText)) continue;
246
+ walk(rule.cssRules, { ...ctx, conditions: ctx.conditions.concat(`@supports ${rule.conditionText}`) });
247
+ continue;
248
+ }
249
+ if (rule.cssRules && (kind === 'CSSContainerRule' || kind === 'CSSScopeRule' || kind === 'CSSStartingStyleRule')) {
250
+ const label = kind === 'CSSContainerRule' ? `@container ${rule.conditionText}`
251
+ : kind === 'CSSScopeRule' ? `@scope (${rule.start ?? ''})` : '@starting-style';
252
+ skipped.push({ condition: label, rules: countRules(rule.cssRules) });
253
+ }
254
+ // @font-face, @property, @namespace, @counter-style: niente da cascata
255
+ }
256
+ };
257
+ const addSheet = (sheet, href, ctx) => {
258
+ let list = null;
259
+ try { list = sheet?.cssRules ?? null; } catch { list = null; }
260
+ if (!list) { opaque.push(href ?? sheet?.href ?? '(unknown)'); return; }
261
+ const ownHref = sheet.href ?? href ?? null;
262
+ const entry = { label: sheetLabel(ownHref, sheet.ownerNode, ownHref ? 0 : (unnamed += 1)), href: ownHref, rules: 0 };
263
+ sheets.push(entry);
264
+ walk(list, { ...ctx, sheet: entry });
265
+ };
266
+ for (const sheet of env.styleSheetsOf(root)) addSheet(sheet, null, { sheet: null, layer: null, conditions: [], parentSelectors: null, parentIndex: null });
267
+ return { rules, sheets, opaque, skipped, rankOf };
268
+ }
269
+
270
+ // Confronto di cascata fra due dichiarazioni della stessa proprietà: > 0 se vince a.
271
+ const cmpLayer = (ra, rb, important) => {
272
+ const ua = ra == null;
273
+ const ub = rb == null;
274
+ if (ua && ub) return 0;
275
+ if (ua !== ub) return (ua === !important) ? 1 : -1; // senza layer: vince fra le normali, perde fra le important
276
+ const n = Math.min(ra.length, rb.length);
277
+ for (let i = 0; i < n; i += 1) {
278
+ if (ra[i] !== rb[i]) return ((ra[i] > rb[i]) === !important) ? 1 : -1; // normali: l'ultimo layer vince
279
+ }
280
+ if (ra.length === rb.length) return 0;
281
+ return ((ra.length < rb.length) === !important) ? 1 : -1; // il layer padre batte i suoi sottolayer fra le normali
282
+ };
283
+ const cmpDecl = (a, b) => {
284
+ if (a.important !== b.important) return a.important ? 1 : -1;
285
+ if (a.inline !== b.inline) return a.inline ? 1 : -1;
286
+ return cmpLayer(a.layerRank, b.layerRank, a.important) || cmpSpec(a.spec, b.spec) || a.order - b.order;
287
+ };
288
+
289
+ const declarationsOf = (style) => {
290
+ const out = [];
291
+ for (let i = 0; i < style.length; i += 1) {
292
+ const prop = style[i];
293
+ out.push({ prop, value: style.getPropertyValue(prop), important: style.getPropertyPriority(prop) === 'important' });
294
+ }
295
+ return out;
296
+ };
297
+
298
+ /** Per ogni proprietà dichiarata su `el`, le dichiarazioni in ordine di cascata (vincitrice prima). */
299
+ function candidatesFor(el, { rules, rankOf }) {
300
+ const byProp = new Map();
301
+ let matched = 0;
302
+ const push = (d) => { if (!byProp.has(d.prop)) byProp.set(d.prop, []); byProp.get(d.prop).push(d); };
303
+ for (const rule of rules) {
304
+ const joined = rule.selectors.length === 1 ? rule.selectors[0] : rule.selectors.join(', ');
305
+ if (!safeMatches(el, joined)) continue;
306
+ matched += 1;
307
+ const matching = rule.selectors.length === 1 ? rule.selectors : rule.selectors.filter((s) => safeMatches(el, s));
308
+ let selector = matching[0] ?? rule.selectors[0];
309
+ let spec = specificity(selector);
310
+ for (const s of matching.slice(1)) { const sp = specificity(s); if (cmpSpec(sp, spec) > 0) { spec = sp; selector = s; } }
311
+ const layerRank = rankOf(rule.layer);
312
+ for (const d of declarationsOf(rule.style)) {
313
+ push({ ...d, inline: false, selector, sheet: rule.sheet, index: rule.index, layer: rule.layer, conditions: rule.conditions, layerRank, spec, order: rule.order });
314
+ }
315
+ }
316
+ if (el.style?.length) {
317
+ for (const d of declarationsOf(el.style)) push({ ...d, inline: true, layerRank: null, spec: [0, 0, 0], order: Infinity });
318
+ }
319
+ for (const list of byProp.values()) list.sort((a, b) => cmpDecl(b, a));
320
+ return { byProp, matched };
321
+ }
322
+
323
+ const fmtSource = (d) => {
324
+ if (d.inline) return { inline: true };
325
+ const out = { selector: d.selector, sheet: d.sheet, rule: d.index };
326
+ if (d.layer) out.layer = d.layer.join('.');
327
+ if (d.conditions?.length) out.conditions = d.conditions;
328
+ return out;
329
+ };
330
+ const fmtEntry = (list, computed, inheritedFrom) => {
331
+ const [winner, ...rest] = list;
332
+ const entry = { value: winner.value };
333
+ if (winner.important) entry.important = true;
334
+ if (computed != null) entry.computed = computed;
335
+ if (inheritedFrom) entry.inherited_from = inheritedFrom;
336
+ entry.source = fmtSource(winner);
337
+ entry.overridden = rest.slice(0, MAX_OVERRIDDEN).map((o) => ({ value: o.value, ...(o.important ? { important: true } : {}), ...fmtSource(o) }));
338
+ if (rest.length > MAX_OVERRIDDEN) entry.overridden_more = rest.length - MAX_OVERRIDDEN;
339
+ return entry;
340
+ };
341
+ const parentOf = (node) => node.parentElement ?? (node.parentNode?.host ?? null);
342
+
343
+ const defaultEnv = () => ({
344
+ styleSheetsOf: (root) => [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])],
345
+ getComputedStyle: (el) => getComputedStyle(el),
346
+ matchMedia: (q) => matchMedia(q).matches,
347
+ supports: (c) => CSS.supports(c),
348
+ });
349
+
350
+ /**
351
+ * @param {Element} el
352
+ * @param {{properties?: string[]|null, include_inherited?: boolean, selector?: string}} [opts]
353
+ * @param {object} [env] - CSSOM finto nei test
354
+ */
355
+ function cssStylesOf(el, opts = {}, env = defaultEnv()) {
356
+ const wanted = Array.isArray(opts.properties) && opts.properties.length ? opts.properties.map((p) => String(p).trim().toLowerCase()) : null;
357
+ const want = (p) => !wanted || wanted.some((q) => p === q || p.startsWith(`${q}-`));
358
+ const collections = new Map();
359
+ const collectionOf = (node) => {
360
+ const root = node.getRootNode ? node.getRootNode() : (node.ownerDocument ?? document);
361
+ if (!collections.has(root)) collections.set(root, collect(root, env));
362
+ return collections.get(root);
363
+ };
364
+ const own = collectionOf(el);
365
+ const computed = env.getComputedStyle(el);
366
+ const computedOf = (p) => computed.getPropertyValue(p);
367
+ const usedSheets = new Set();
368
+ const noteSheets = (list) => { for (const d of list) if (!d.inline) usedSheets.add(d.sheet); };
369
+
370
+ const properties = {};
371
+ const { byProp, matched } = candidatesFor(el, own);
372
+ for (const [prop, list] of byProp) {
373
+ if (!want(prop)) continue;
374
+ properties[prop] = fmtEntry(list, computedOf(prop), null);
375
+ noteSheets(list);
376
+ }
377
+ if (opts.include_inherited) {
378
+ let node = parentOf(el);
379
+ for (let depth = 0; node && node.nodeType === 1 && depth < MAX_ANCESTORS; depth += 1, node = parentOf(node)) {
380
+ const ancestor = candidatesFor(node, collectionOf(node));
381
+ for (const [prop, list] of ancestor.byProp) {
382
+ if (properties[prop] || !inherits(prop) || !want(prop)) continue;
383
+ properties[prop] = fmtEntry(list, computedOf(prop), describe(node));
384
+ noteSheets(list);
385
+ }
386
+ }
387
+ }
388
+
389
+ const sorted = {};
390
+ for (const k of Object.keys(properties).sort()) sorted[k] = properties[k];
391
+ const out = { selector: opts.selector ?? null, element: describe(el), matched_rules: matched };
392
+ const sheets = [];
393
+ for (const c of collections.values()) for (const s of c.sheets) if (usedSheets.has(s.label)) sheets.push(s.href ? { sheet: s.label, href: s.href, rules: s.rules } : { sheet: s.label, rules: s.rules });
394
+ out.stylesheets = sheets;
395
+ const opaque = [...collections.values()].flatMap((c) => c.opaque);
396
+ if (opaque.length) out.opaque_stylesheets = opaque;
397
+ const skipped = [...collections.values()].flatMap((c) => c.skipped);
398
+ if (skipped.length) out.skipped = skipped;
399
+ out.properties = sorted;
400
+ return out;
401
+ }
402
+
403
+ globalThis.__cbCssStyles = cssStylesOf;
404
+ globalThis.__cbCssSpecificity = specificity;
405
+ })();
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.20.0",
5
- "description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.21.0",
5
+ "description": "Bridge your browser to Claude Code: 60 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",
8
8
  "permissions": [
@@ -357,6 +357,8 @@ async function executeCommand(msg) {
357
357
  return await cmdGetPerformance(params);
358
358
  case 'query_dom':
359
359
  return await cmdQueryDom(params);
360
+ case 'get_css_styles':
361
+ return await cmdGetCssStyles(params);
360
362
  case 'modify_dom':
361
363
  return await cmdModifyDom(params);
362
364
  case 'inject_css':
@@ -1564,6 +1566,42 @@ async function cmdQueryDom({ selector, properties, limit = 50, tab_id, frame_id
1564
1566
  return { count: elements.length, elements };
1565
1567
  }
1566
1568
 
1569
+ // --- DevTools: get_css_styles ---
1570
+
1571
+ // La cascata sta in lib/css-cascade.js, iniettato prima della funzione come
1572
+ // element-label.js: chrome.scripting serializza solo la funzione. Mondo
1573
+ // ISOLATED: il CSSOM è lo stesso e la pagina non vede né manomette la libreria.
1574
+ async function cmdGetCssStyles({ selector, properties, include_inherited = false, tab_id, frame_id }) {
1575
+ if (!selector) throw new Error('Missing required parameter: selector');
1576
+ const tabId = await resolveTabId(tab_id);
1577
+ const target = scriptTarget(tabId, frame_id);
1578
+ await chrome.scripting.executeScript({ target, files: ['lib/css-cascade.js'], world: 'ISOLATED' });
1579
+ const results = await chrome.scripting.executeScript({
1580
+ target,
1581
+ func: (sel, props, inherited) => {
1582
+ function deepQuery(sel) {
1583
+ if (!sel.includes('>>>')) return document.querySelector(sel);
1584
+ const parts = sel.split('>>>').map((s) => s.trim());
1585
+ let ctx = document;
1586
+ for (let i = 0; i < parts.length; i++) {
1587
+ const found = ctx.querySelector(parts[i]);
1588
+ if (!found) return null;
1589
+ if (i === parts.length - 1) return found;
1590
+ if (!found.shadowRoot) return null;
1591
+ ctx = found.shadowRoot;
1592
+ }
1593
+ return null;
1594
+ }
1595
+ const el = deepQuery(sel);
1596
+ if (!el) throw new Error(`Element not found: ${sel}`);
1597
+ return globalThis.__cbCssStyles(el, { selector: sel, properties: props, include_inherited: inherited });
1598
+ },
1599
+ args: [selector, properties ?? null, Boolean(include_inherited)],
1600
+ world: 'ISOLATED',
1601
+ });
1602
+ return results?.[0]?.result ?? {};
1603
+ }
1604
+
1567
1605
  // --- DevTools: modify_dom ---
1568
1606
 
1569
1607
  async function cmdModifyDom({ selector, action, name, value, className, tab_id, frame_id }) {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.20.0",
4
+ "version": "1.21.0",
5
5
  "type": "module",
6
- "description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
6
+ "description": "Chrome Bridge by frsorrentino: MCP server that gives Claude Code your real, logged-in Chrome — 60 tools, 2.75× fewer turns than Claude in Chrome, ChromeOS included.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "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
 
@@ -18,6 +18,7 @@ export const MessageType = Object.freeze({
18
18
  GET_STORAGE: 'get_storage',
19
19
  GET_PERFORMANCE: 'get_performance',
20
20
  QUERY_DOM: 'query_dom',
21
+ GET_CSS_STYLES: 'get_css_styles',
21
22
  MODIFY_DOM: 'modify_dom',
22
23
  INJECT_CSS: 'inject_css',
23
24
  READ_CONSOLE: 'read_console',
@@ -92,7 +93,7 @@ export const MessageType = Object.freeze({
92
93
  });
93
94
 
94
95
  // Versione
95
- export const VERSION = '1.20.0';
96
+ export const VERSION = '1.21.0';
96
97
 
97
98
  // Configurazione
98
99
  export const DEFAULT_PORT = 8765;
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
@@ -299,6 +307,7 @@ export const TOOL_ANNOTATIONS = {
299
307
  measure_spacing: ro(),
300
308
  monitor_network: ro(true),
301
309
  query_dom: ro(),
310
+ get_css_styles: ro(),
302
311
  read_page: ro(),
303
312
  screenshot: ro(),
304
313
  wait_for: ro(),
@@ -364,7 +373,7 @@ export const TOOL_ANNOTATIONS = {
364
373
  * @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
365
374
  * @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
366
375
  * @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
376
+ * @param {{noJs?: boolean, writeRoot?: string|null, readRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT / CHROME_BRIDGE_READ_ROOT
368
377
  */
369
378
  /** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
370
379
  function observedProcedure(name, d) {
@@ -396,6 +405,20 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
396
405
  if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
397
406
  throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
398
407
  };
408
+ const READ_ROOT = options.readRoot ? resolve(String(options.readRoot)) : null;
409
+ // realpath: un symlink innocuo verso ~/.ssh non deve passare.
410
+ const guardRead = async (p) => {
411
+ const abs = await realpath(resolve(String(p)));
412
+ const inside = (root) => abs === root || abs.startsWith(root + sep);
413
+ if (READ_ROOT) {
414
+ if (inside(READ_ROOT)) return abs;
415
+ throw new Error(`Refusing to read ${abs}: outside CHROME_BRIDGE_READ_ROOT (${READ_ROOT})`);
416
+ }
417
+ if (SECRET_DIRS.some(inside) || SECRET_FILES.test(abs)) {
418
+ 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.`);
419
+ }
420
+ return abs;
421
+ };
399
422
  const activeCaps = caps === 'all'
400
423
  ? ['core', ...Object.keys(TOOL_CAPS)]
401
424
  : ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
@@ -619,6 +642,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
619
642
  // scoprire che esiste ma è in un gruppo disattivato.
620
643
  js_evaluation: !noJs,
621
644
  write_root: WRITE_ROOT,
645
+ read_root: READ_ROOT,
622
646
  caps_active: activeCaps,
623
647
  caps_available: ['core', ...Object.keys(TOOL_CAPS)],
624
648
  session_tab_id: sessionTabId,
@@ -959,6 +983,27 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
959
983
  }
960
984
  );
961
985
 
986
+ // --- get_css_styles ---
987
+ server.tool(
988
+ 'get_css_styles',
989
+ 'Why an element looks the way it does: for each CSS property the winning declaration — selector, stylesheet, '
990
+ + 'rule position, !important, @layer, @media — and the ones it overrides, like the Styles panel of DevTools; '
991
+ + 'query_dom gives the computed value, this gives who set it. Read-only, from the CSSOM: stylesheets of '
992
+ + 'another origin without CORS are opaque and listed by href; user-agent styles, @container and @scope rules '
993
+ + 'are not evaluated. properties narrows the output.',
994
+ {
995
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. Only the first match is inspected'),
996
+ properties: z.array(z.string()).optional().describe('Only these, e.g. ["margin-top","--brand"]; margin covers its longhands. Omitted = every property a matching rule sets'),
997
+ include_inherited: z.boolean().optional().default(false).describe('Also inherited properties (color, font-*, custom properties) with the ancestor that sets them'),
998
+ tab_id: tabId,
999
+ frame_id: frameId,
1000
+ },
1001
+ async ({ selector, properties, include_inherited, tab_id, frame_id }) => {
1002
+ const data = await send(MessageType.GET_CSS_STYLES, { selector, properties, include_inherited, tab_id, frame_id });
1003
+ return { content: [{ type: 'text', text: jsonText(data, DEFAULT_MAX_OUTPUT, 'properties') }] };
1004
+ }
1005
+ );
1006
+
962
1007
  // --- modify_dom ---
963
1008
  server.tool(
964
1009
  'modify_dom',
@@ -1519,12 +1564,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1519
1564
  'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
1520
1565
  {
1521
1566
  selector: z.string().describe('The file input to fill; ">>>" pierces shadow DOM'),
1522
- path: z.string().describe('Absolute path on the server machine'),
1567
+ path: z.string().describe('Absolute path on the server machine; keys and credentials (~/.ssh, .env, *.pem) refused'),
1523
1568
  mime_type: z.string().optional().describe('Default: inferred from extension'),
1524
1569
  tab_id: tabId,
1525
1570
  },
1526
1571
  async ({ selector, path, mime_type, tab_id }) => {
1527
- const buf = await readFile(path);
1572
+ const buf = await readFile(await guardRead(path));
1528
1573
  if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
1529
1574
  const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
1530
1575
  const data = await send(MessageType.UPLOAD_FILE, {
@@ -140,7 +140,10 @@ top group and re-running both.
140
140
  Triggers: "does it use the design's fonts/colours?", "does it match the
141
141
  Figma?", «somiglia al design?».
142
142
  `query_dom({selector, properties:['font-family','font-size','color','background-color','margin','padding']})`
143
- on the key elements; compare with the token file in the repo. Spacing between
143
+ on the key elements; compare with the token file in the repo. Why a value is
144
+ what it is («chi mi azzera il margine?»): `get_css_styles({selector, properties:['margin-top']})`
145
+ → the winning rule with selector, stylesheet and rule position, plus the ones it
146
+ overrides; `include_inherited:true` for `color` and `font-*`. Spacing between
144
147
  two elements: `measure_spacing({selector1, selector2})`. Visual comparison:
145
148
  `viewport_resize({width, height})` to the mockup's size, then
146
149
  `screenshot_diff({action:'baseline', name:'home', from_file:'./mockups/home.png'})`