@mehmoodqureshi/chrome-mcp 0.6.6 → 0.7.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.
Files changed (44) hide show
  1. package/README.md +116 -0
  2. package/dist/shared/observers.d.ts +110 -0
  3. package/dist/shared/observers.js +127 -0
  4. package/dist/shared/page-fns.d.ts +46 -0
  5. package/dist/shared/page-fns.js +292 -0
  6. package/dist/shared/policy.d.ts +9 -0
  7. package/dist/shared/policy.js +16 -0
  8. package/dist/shared/protocol.d.ts +11 -2
  9. package/dist/shared/protocol.js +3 -0
  10. package/dist/shared/snapshot.d.ts +2 -0
  11. package/dist/shared/snapshot.js +8 -1
  12. package/dist/src/bridge/workspace.d.ts +6 -0
  13. package/dist/src/bridge/workspace.js +20 -0
  14. package/dist/src/cli.js +13 -0
  15. package/dist/src/config.js +31 -0
  16. package/dist/src/executor/extension-executor.d.ts +27 -13
  17. package/dist/src/executor/extension-executor.js +53 -12
  18. package/dist/src/executor/stub-executor.d.ts +45 -1
  19. package/dist/src/executor/stub-executor.js +53 -8
  20. package/dist/src/executor/types.d.ts +72 -13
  21. package/dist/src/mcp/audit.d.ts +39 -0
  22. package/dist/src/mcp/audit.js +60 -0
  23. package/dist/src/mcp/batch.js +61 -9
  24. package/dist/src/mcp/helpers.d.ts +4 -4
  25. package/dist/src/mcp/helpers.js +9 -4
  26. package/dist/src/mcp/limits.d.ts +43 -0
  27. package/dist/src/mcp/limits.js +87 -0
  28. package/dist/src/mcp/locate.d.ts +45 -0
  29. package/dist/src/mcp/locate.js +105 -0
  30. package/dist/src/mcp/log.d.ts +17 -0
  31. package/dist/src/mcp/log.js +43 -0
  32. package/dist/src/mcp/redact.d.ts +48 -0
  33. package/dist/src/mcp/redact.js +92 -0
  34. package/dist/src/mcp/server.d.ts +1 -2
  35. package/dist/src/mcp/server.js +16 -12
  36. package/dist/src/mcp/snapdiff.d.ts +43 -0
  37. package/dist/src/mcp/snapdiff.js +92 -0
  38. package/dist/src/mcp/tools.js +481 -41
  39. package/dist/src/security/policy.d.ts +5 -0
  40. package/dist/src/security/policy.js +6 -0
  41. package/docs/BLUEPRINT.md +15 -1
  42. package/extension-dist/background.js +617 -214
  43. package/extension-dist/page-hook.js +215 -0
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -201,6 +201,99 @@ silently mis-routed. (`tab_new`, `tabs_list`, `chrome_status` are exempt.)
201
201
  > `navigate`, to open without losing the current tab). Pass `active: false` to
202
202
  > open in the background; parallel batches do this automatically.
203
203
 
204
+ ### Reaching into iframes and shadow roots
205
+
206
+ A selector that "should" match but doesn't almost always means the element is
207
+ somewhere your selector cannot reach: inside an `<iframe>` (checkout widgets,
208
+ OAuth consent screens, embedded editors) or inside a web component's shadow root.
209
+
210
+ Shadow roots are handled for you — every selector and every `ref` now resolves
211
+ through open shadow roots, so anything `snapshot` shows you is something you can
212
+ click. (It used to show you elements no click could reach: the snapshot walked
213
+ shadow roots, the actions did not.)
214
+
215
+ Frames are opt-in, because reaching into one is a decision:
216
+
217
+ ```jsonc
218
+ frames_list {} // what frames exist, and their URLs
219
+ click { "selector": "#pay", "allFrames": true } // find it in whichever frame has it
220
+ get_text { "frameId": 7 } // pin one frame
221
+ ```
222
+
223
+ Every frame is authorized against **its own** URL before anything runs in it, so
224
+ an allowlisted page embedding a third-party iframe does not become a way to read
225
+ that third party. Frames whose origin isn't on your allowlist are skipped.
226
+
227
+ ### Seeing why a page broke — `console_logs`, `network_log`, `dialogs`
228
+
229
+ Reading the DOM tells you what a page looks like after it failed, not why. With
230
+ `--enable-observers`, an in-page hook records console output, uncaught errors,
231
+ and `fetch`/`XMLHttpRequest` traffic, and intercepts native dialogs:
232
+
233
+ ```jsonc
234
+ console_logs { "level": "error" } // the exception the page swallowed
235
+ network_log { "failedOnly": true } // the 500 behind the blank screen
236
+ dialogs { "policy": "accept" } // answer confirm() with true from here on
237
+ ```
238
+
239
+ It is **off by default and deliberately so**: the hook patches `console`,
240
+ `fetch`, `XMLHttpRequest` and the dialog functions on every allowlisted page in
241
+ your real browser. When it's on, it is registered only for the domains on your
242
+ allowlist, at document_start (so it catches load-time failures), and nothing it
243
+ records leaves the page until a tool call reads it — through the same gate as any
244
+ other page read.
245
+
246
+ Dialog interception is also a fix, not just an observation: `alert`/`confirm`/
247
+ `beforeunload` block the renderer, so a click that opened one used to hang every
248
+ injected script until the command timed out and reported `TIMEOUT` with nothing
249
+ to point at. With observers on, the dialog is answered (`dismiss` by default:
250
+ confirm → false, prompt → null) and recorded.
251
+
252
+ > **What `network_log` sees:** the requests page code makes — `fetch` and
253
+ > `XMLHttpRequest`, with method, URL, status and duration — plus Resource Timing
254
+ > entries (scripts, images, styles) when you ask for them. Not the document
255
+ > request, redirects, or headers. That is the cost of not holding a debugger
256
+ > session open on your browser.
257
+
258
+ ### Only what changed — `snapshot { diff: true }`
259
+
260
+ A snapshot is the most expensive read in the tool surface, and the loop that uses
261
+ it most (snapshot → click → snapshot) re-sends a page that is mostly identical
262
+ every time. Ask for the delta instead:
263
+
264
+ ```jsonc
265
+ snapshot { "diff": true } // added / removed / changed only
266
+ click { "selector": "#save", "snapshotAfter": true } // what the click changed
267
+ ```
268
+
269
+ Nodes are matched across snapshots by role + accessible name, not by `ref` —
270
+ refs renumber in document order on every snapshot, so diffing on them would
271
+ report an unchanged button as removed-and-re-added the moment anything above it
272
+ appears.
273
+
274
+ ### Targeting by role and name
275
+
276
+ Actions accept a locator instead of a CSS selector, so you don't need a snapshot
277
+ first just to learn a ref:
278
+
279
+ ```jsonc
280
+ click { "role": "button", "name": "Sign in" }
281
+ type { "role": "textbox", "name": "Email", "text": "a@b.com" }
282
+ ```
283
+
284
+ Resolution is server-side and refuses to guess: an ambiguous locator fails with
285
+ the candidates listed rather than acting on the first one (pass `nth` to pick).
286
+
287
+ ### Printing — `print_pdf`
288
+
289
+ ```jsonc
290
+ print_pdf { "landscape": true }
291
+ ```
292
+
293
+ Renders through Chrome's own print pipeline and saves to the task's `results/`
294
+ dir, returning the path and size. The bytes themselves are never returned — a
295
+ PDF is megabytes of base64 no model can read.
296
+
204
297
  ## Status
205
298
 
206
299
  v0.5.0 — **safe multi-tab concurrency.** Adds the `batch` fan-out tool, makes
@@ -261,8 +354,31 @@ status badge, and a stable pairing token (`--persist-token`).
261
354
  chrome-mcp --allow-domain example.com --enable-mutations
262
355
  chrome-mcp --policy ./policy.json # see policy.example.json
263
356
  chrome-mcp --unsafe-all-domains # loud footgun
357
+ chrome-mcp --enable-observers # console/network/dialog capture (patches page globals)
358
+ chrome-mcp --redact # scrub secret-shaped strings out of page reads
264
359
  ```
265
360
 
361
+ **What comes back is gated too.** The allowlist decides which pages may be read;
362
+ it says nothing about what is on them. A logged-in page routinely renders a
363
+ session token into a script tag or an API key onto a settings screen.
364
+
365
+ - **Password field values are always suppressed** — in `get_html`, and in
366
+ `snapshot`, where the field still appears (so you can type into it) flagged
367
+ `secret: true` with no value. No flag, no opt-in: nobody wants those characters.
368
+ - `--redact` additionally scrubs secret-shaped strings — JWTs, AWS/GitHub/Slack/
369
+ Google keys, `Bearer` headers, private-key blocks — out of `get_text`,
370
+ `get_html`, `read_as_markdown` and `eval`. It is opt-in because a pattern will
371
+ eventually fire on something you actually wanted. `--redact-pattern <regex>`
372
+ adds your own (and implies `--redact`); an invalid one fails at startup rather
373
+ than silently never matching.
374
+ - Redaction runs **before** the output cap, so a truncated read cannot leak what
375
+ a full one would have hidden.
376
+
377
+ Every call is recorded to the task's `history.jsonl` with the URL it touched, the
378
+ policy verdict (`allowed`/`denied`), how long it took, how many bytes came back,
379
+ and how many secrets were scrubbed — so "what did the agent do in my browser" has
380
+ an answer after the fact.
381
+
266
382
  The per-boot 256-bit token in `~/.chrome-mcp/handshake.json` (mode 0600) is the
267
383
  only trust boundary; it is never written to stdout/stderr. On POSIX the mode is
268
384
  re-verified after every write and the server **fails closed** if the file ends up
@@ -0,0 +1,110 @@
1
+ /**
2
+ * shared/observers.ts — the record shapes produced by the in-page observer hook
3
+ * (`extension/src/page/hook.ts`) and consumed by the `console_logs`,
4
+ * `network_log` and `dialogs` tools.
5
+ *
6
+ * Why an in-page hook rather than the CDP `Log`/`Console`/`Network` domains: the
7
+ * executor attaches `chrome.debugger` for the duration of ONE op and detaches in
8
+ * `finally` (that is what keeps the "being debugged" banner off the user's
9
+ * browser and keeps other CDP clients working). Observing console and network
10
+ * needs a session that spans commands, which that model cannot provide. A
11
+ * MAIN-world hook installed at document_start costs no attach, survives the
12
+ * service worker being recycled (the buffers live in the page), and captures
13
+ * exactly what page code did — which is what an agent debugging a page is
14
+ * asking about.
15
+ *
16
+ * The trade-off, stated plainly: `network_log` sees `fetch` and
17
+ * `XMLHttpRequest` — the calls page code makes — plus whatever the Resource
18
+ * Timing API reports for everything else. It does not see the document request,
19
+ * redirects, or request/response headers.
20
+ */
21
+ import type { WirePolicy } from './protocol';
22
+ /** Bumped when the hook's on-page state shape changes. */
23
+ export declare const OBSERVER_HOOK_VERSION: 1;
24
+ /** The MAIN-world global the hook installs its state on. */
25
+ export declare const OBSERVER_GLOBAL: "__chromeMcpObservers";
26
+ /** Ring-buffer ceilings, enforced in-page so a chatty site cannot grow unbounded. */
27
+ export declare const MAX_CONSOLE_ENTRIES = 500;
28
+ export declare const MAX_NETWORK_ENTRIES = 300;
29
+ export declare const MAX_DIALOG_ENTRIES = 50;
30
+ export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug' | 'exception';
31
+ export interface ConsoleEntry {
32
+ /** Monotonic per-page sequence, so a caller can poll for "what is new". */
33
+ seq: number;
34
+ ts: number;
35
+ level: ConsoleLevel;
36
+ text: string;
37
+ /** Present for `exception`: the error's stack, trimmed. */
38
+ stack?: string;
39
+ url?: string;
40
+ }
41
+ export interface NetworkEntry {
42
+ seq: number;
43
+ ts: number;
44
+ /** 'fetch' | 'xhr' | 'resource' (the Resource Timing fallback). */
45
+ via: 'fetch' | 'xhr' | 'resource';
46
+ method: string;
47
+ url: string;
48
+ status?: number;
49
+ ok?: boolean;
50
+ durationMs?: number;
51
+ /** Set when the request rejected (network error, CORS, abort). */
52
+ error?: string;
53
+ /** Resource Timing only: the initiator type Chrome reports (script, img, css…). */
54
+ initiator?: string;
55
+ bytes?: number;
56
+ }
57
+ export type DialogKind = 'alert' | 'confirm' | 'prompt' | 'beforeunload';
58
+ export interface DialogEntry {
59
+ seq: number;
60
+ ts: number;
61
+ kind: DialogKind;
62
+ message: string;
63
+ /** What the hook answered on the page's behalf. */
64
+ answered: string;
65
+ }
66
+ /**
67
+ * How the hook answers a native dialog. `dismiss` is the default: it is what a
68
+ * blocked renderer would eventually be told anyway, and it never confirms a
69
+ * destructive action the agent did not ask for. `accept` is opt-in per tab.
70
+ */
71
+ export type DialogPolicy = 'dismiss' | 'accept';
72
+ export interface ObserverReadResult {
73
+ /** Absent hook (observers disabled, or the page loaded before it registered). */
74
+ installed: boolean;
75
+ hookVersion?: number;
76
+ console?: ConsoleEntry[];
77
+ network?: NetworkEntry[];
78
+ dialogs?: DialogEntry[];
79
+ dialogPolicy?: DialogPolicy;
80
+ /** True when the ring buffer dropped older entries before this read. */
81
+ dropped?: boolean;
82
+ }
83
+ /**
84
+ * Runs IN THE PAGE (MAIN world), serialized to source — so it must be
85
+ * self-contained and takes the global's name rather than importing the constant.
86
+ *
87
+ * Draining is explicit: `clear` empties the buffers it returned, and `sinceSeq`
88
+ * lets a caller poll for only what is new. Nothing here reaches out; it reads
89
+ * what the hook already recorded on this page.
90
+ */
91
+ export declare function readObservers(globalName: string, opts: {
92
+ console?: boolean;
93
+ network?: boolean;
94
+ dialogs?: boolean;
95
+ sinceSeq?: number;
96
+ limit?: number;
97
+ clear?: boolean;
98
+ setPolicy?: string | null;
99
+ promptText?: string | null;
100
+ includeResources?: boolean;
101
+ }): unknown;
102
+ /**
103
+ * Translate the domain allowlist into Chrome match patterns for registering the
104
+ * hook as a content script. A bare host becomes `*://host/*`; `*.host` becomes
105
+ * `*://*.host/*` (which Chrome reads as the host AND its subdomains, matching
106
+ * how the allowlist itself behaves); `*` becomes `<all_urls>`. Entries carrying
107
+ * no host are dropped rather than widened — the page the tool may not read is
108
+ * also the page it must not instrument.
109
+ */
110
+ export declare function observerMatches(policy: WirePolicy): string[];
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * shared/observers.ts — the record shapes produced by the in-page observer hook
4
+ * (`extension/src/page/hook.ts`) and consumed by the `console_logs`,
5
+ * `network_log` and `dialogs` tools.
6
+ *
7
+ * Why an in-page hook rather than the CDP `Log`/`Console`/`Network` domains: the
8
+ * executor attaches `chrome.debugger` for the duration of ONE op and detaches in
9
+ * `finally` (that is what keeps the "being debugged" banner off the user's
10
+ * browser and keeps other CDP clients working). Observing console and network
11
+ * needs a session that spans commands, which that model cannot provide. A
12
+ * MAIN-world hook installed at document_start costs no attach, survives the
13
+ * service worker being recycled (the buffers live in the page), and captures
14
+ * exactly what page code did — which is what an agent debugging a page is
15
+ * asking about.
16
+ *
17
+ * The trade-off, stated plainly: `network_log` sees `fetch` and
18
+ * `XMLHttpRequest` — the calls page code makes — plus whatever the Resource
19
+ * Timing API reports for everything else. It does not see the document request,
20
+ * redirects, or request/response headers.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.MAX_DIALOG_ENTRIES = exports.MAX_NETWORK_ENTRIES = exports.MAX_CONSOLE_ENTRIES = exports.OBSERVER_GLOBAL = exports.OBSERVER_HOOK_VERSION = void 0;
24
+ exports.readObservers = readObservers;
25
+ exports.observerMatches = observerMatches;
26
+ const policy_1 = require("./policy");
27
+ /** Bumped when the hook's on-page state shape changes. */
28
+ exports.OBSERVER_HOOK_VERSION = 1;
29
+ /** The MAIN-world global the hook installs its state on. */
30
+ exports.OBSERVER_GLOBAL = '__chromeMcpObservers';
31
+ /** Ring-buffer ceilings, enforced in-page so a chatty site cannot grow unbounded. */
32
+ exports.MAX_CONSOLE_ENTRIES = 500;
33
+ exports.MAX_NETWORK_ENTRIES = 300;
34
+ exports.MAX_DIALOG_ENTRIES = 50;
35
+ /**
36
+ * Runs IN THE PAGE (MAIN world), serialized to source — so it must be
37
+ * self-contained and takes the global's name rather than importing the constant.
38
+ *
39
+ * Draining is explicit: `clear` empties the buffers it returned, and `sinceSeq`
40
+ * lets a caller poll for only what is new. Nothing here reaches out; it reads
41
+ * what the hook already recorded on this page.
42
+ */
43
+ function readObservers(globalName, opts) {
44
+ const state = window[globalName];
45
+ if (!state)
46
+ return { installed: false };
47
+ if (opts.setPolicy === 'dismiss' || opts.setPolicy === 'accept')
48
+ state.dialogPolicy = opts.setPolicy;
49
+ if (typeof opts.promptText === 'string')
50
+ state.promptText = opts.promptText;
51
+ const since = typeof opts.sinceSeq === 'number' ? opts.sinceSeq : 0;
52
+ const limit = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : 200;
53
+ const take = (buf) => {
54
+ const picked = buf.filter((e) => e.seq > since);
55
+ return picked.length > limit ? picked.slice(picked.length - limit) : picked;
56
+ };
57
+ // Resource Timing covers what the fetch/XHR patches structurally cannot see
58
+ // (documents, scripts, images, styles). It carries no status code, so those
59
+ // entries report timing and size only — stated, not implied.
60
+ if (opts.network && opts.includeResources) {
61
+ try {
62
+ const entries = performance.getEntriesByType('resource');
63
+ for (let i = state.resourceCursor; i < entries.length; i++) {
64
+ const r = entries[i];
65
+ state.network.push({
66
+ seq: ++state.seq,
67
+ ts: Math.round(performance.timeOrigin + r.startTime),
68
+ via: 'resource',
69
+ method: 'GET',
70
+ url: r.name.length > 2000 ? `${r.name.slice(0, 2000)}…` : r.name,
71
+ durationMs: Math.round(r.duration),
72
+ initiator: r.initiatorType,
73
+ bytes: r.transferSize || undefined,
74
+ });
75
+ }
76
+ state.resourceCursor = entries.length;
77
+ }
78
+ catch {
79
+ /* Resource Timing unavailable — the fetch/XHR records still stand */
80
+ }
81
+ }
82
+ const out = {
83
+ installed: true,
84
+ hookVersion: state.v,
85
+ dialogPolicy: state.dialogPolicy,
86
+ dropped: state.dropped,
87
+ };
88
+ if (opts.console)
89
+ out.console = take(state.console);
90
+ if (opts.network)
91
+ out.network = take(state.network);
92
+ if (opts.dialogs)
93
+ out.dialogs = take(state.dialogs);
94
+ if (opts.clear) {
95
+ if (opts.console)
96
+ state.console.length = 0;
97
+ if (opts.network)
98
+ state.network.length = 0;
99
+ if (opts.dialogs)
100
+ state.dialogs.length = 0;
101
+ state.dropped = false;
102
+ }
103
+ return out;
104
+ }
105
+ /**
106
+ * Translate the domain allowlist into Chrome match patterns for registering the
107
+ * hook as a content script. A bare host becomes `*://host/*`; `*.host` becomes
108
+ * `*://*.host/*` (which Chrome reads as the host AND its subdomains, matching
109
+ * how the allowlist itself behaves); `*` becomes `<all_urls>`. Entries carrying
110
+ * no host are dropped rather than widened — the page the tool may not read is
111
+ * also the page it must not instrument.
112
+ */
113
+ function observerMatches(policy) {
114
+ if (policy.allowObservers !== true)
115
+ return [];
116
+ const out = new Set();
117
+ for (const raw of policy.allowDomains ?? []) {
118
+ const p = (0, policy_1.normalizeDomainPattern)(raw);
119
+ if (!p)
120
+ continue;
121
+ if (p === '*')
122
+ return ['<all_urls>'];
123
+ out.add(`*://${p}/*`);
124
+ }
125
+ return [...out];
126
+ }
127
+ //# sourceMappingURL=observers.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * shared/page-fns.ts — the ONE function that runs inside a page for every
3
+ * DOM-touching command.
4
+ *
5
+ * MUST be self-contained: `chrome.scripting.executeScript` serializes it to
6
+ * source, so it may not close over anything from this module. That constraint is
7
+ * exactly why every op lives in one function instead of ten — the shared
8
+ * helpers below (`deepQuery` above all) can then be written once and are
9
+ * automatically used by every op.
10
+ *
11
+ * `deepQuery` is the reason this file exists. `snapshot` deliberately walks open
12
+ * shadow roots and stamps `data-mcp-ref` on what it finds there, but every
13
+ * action used to resolve that ref with a plain `document.querySelector`, which
14
+ * cannot cross a shadow boundary. So the snapshot advertised elements that no
15
+ * click could ever reach — a structural dead end on every web-component site.
16
+ * One resolver, used by every op, is what closes it.
17
+ */
18
+ /** Ops the page-side dispatcher understands. */
19
+ export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
20
+ export interface PageOpArgs {
21
+ op: PageOpName;
22
+ selector?: string | null;
23
+ text?: string;
24
+ clear?: boolean;
25
+ outer?: boolean;
26
+ values?: string[];
27
+ x?: number | null;
28
+ y?: number | null;
29
+ deltaX?: number | null;
30
+ deltaY?: number | null;
31
+ storageOp?: string;
32
+ key?: string | null;
33
+ value?: string | null;
34
+ session?: boolean;
35
+ textContains?: string | null;
36
+ gone?: boolean;
37
+ timeoutMs?: number;
38
+ interval?: number;
39
+ }
40
+ /**
41
+ * Runs IN THE PAGE (or in one frame of it). Returns a plain JSON-able object;
42
+ * `found: false` means the selector matched nothing, which the caller renders as
43
+ * SELECTOR_NOT_FOUND. Never throws across the boundary — a page that blows up
44
+ * inside an op is reported as `{ ok: false, error }`.
45
+ */
46
+ export declare function pageOp(a: PageOpArgs): unknown;