mcp-browser-extension 0.9.30 → 0.9.32

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 (50) hide show
  1. package/README.md +77 -14
  2. package/SETUP.md +40 -7
  3. package/dist/shared/activity.d.ts +25 -0
  4. package/dist/shared/activity.js +72 -0
  5. package/dist/shared/browser-permissions.d.ts +20 -0
  6. package/dist/shared/browser-permissions.js +40 -0
  7. package/dist/shared/client-auth.d.ts +28 -0
  8. package/dist/shared/client-auth.js +38 -0
  9. package/dist/shared/observers.d.ts +1 -1
  10. package/dist/shared/observers.js +1 -3
  11. package/dist/shared/policy.d.ts +5 -7
  12. package/dist/shared/policy.js +11 -26
  13. package/dist/shared/protocol.d.ts +4 -0
  14. package/dist/shared/protocol.js +3 -3
  15. package/dist/shared/screenshot.d.ts +4 -2
  16. package/dist/shared/screenshot.js +13 -8
  17. package/dist/src/access-browser.d.ts +15 -0
  18. package/dist/src/access-browser.js +202 -0
  19. package/dist/src/access.d.ts +2 -0
  20. package/dist/src/access.js +92 -18
  21. package/dist/src/bridge/client-identity.d.ts +10 -0
  22. package/dist/src/bridge/client-identity.js +28 -0
  23. package/dist/src/bridge/connection.d.ts +5 -0
  24. package/dist/src/bridge/connection.js +4 -12
  25. package/dist/src/bridge/peer.d.ts +4 -19
  26. package/dist/src/bridge/peer.js +17 -8
  27. package/dist/src/bridge/server.d.ts +7 -16
  28. package/dist/src/bridge/server.js +64 -17
  29. package/dist/src/bridge/site-grants.d.ts +1 -2
  30. package/dist/src/bridge/site-grants.js +4 -3
  31. package/dist/src/cli.js +1 -3
  32. package/dist/src/config.js +16 -5
  33. package/dist/src/executor/cdp-executor.d.ts +3 -0
  34. package/dist/src/executor/cdp-executor.js +69 -25
  35. package/dist/src/mcp/server.js +5 -3
  36. package/dist/src/mcp/tools.d.ts +0 -12
  37. package/dist/src/mcp/tools.js +48 -22
  38. package/dist/src/remote/key.js +12 -2
  39. package/dist/src/security/policy.d.ts +4 -0
  40. package/dist/src/security/policy.js +3 -1
  41. package/docs/BLUEPRINT.md +6 -1
  42. package/docs/CLIENT-APPROVAL.md +150 -0
  43. package/extension-dist/activity-dashboard.css +43 -0
  44. package/extension-dist/background.js +1036 -73
  45. package/extension-dist/manifest.json +10 -9
  46. package/extension-dist/options.html +101 -32
  47. package/extension-dist/options.js +558 -70
  48. package/extension-dist/popup.html +164 -90
  49. package/extension-dist/popup.js +576 -79
  50. package/package.json +3 -2
@@ -1,21 +1,4 @@
1
- /**
2
- * src/bridge/peer.ts — let several mcp-browser-extension servers share one Chrome.
3
- *
4
- * Every MCP host session (each Claude terminal/tab) spawns its own mcp-browser-extension,
5
- * but a browser's extension dials exactly one port. So the first server to bind
6
- * the port is the HUB: it owns the extension connections. A later server that
7
- * finds the port held by a live mcp-browser-extension joins it as a PEER over the same
8
- * port and relays its commands through the hub, instead of killing it. If the
9
- * hub exits, its peers race to bind the port; the winner becomes the new hub
10
- * (with the same token, so the extension re-pairs on its own) and the rest join
11
- * it.
12
- *
13
- * Peers authenticate with the same token the extension uses — the handshake
14
- * file is 0600, so only the same OS user can read it — and speak a small
15
- * server-to-server protocol that the extension never sees:
16
- * peer → hub: peer_hello, relay (a wire command), peer_rename, peer_blocked
17
- * hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
18
- */
1
+ import type { ClientProof } from '../../shared/client-auth';
19
2
  import { PROTOCOL_VERSION, type WireMethod } from '../../shared/protocol';
20
3
  import type { PairedProfile } from './server';
21
4
  /** How long a joining server waits for the hub's welcome before giving up. */
@@ -35,9 +18,11 @@ export interface RelayFrame {
35
18
  tabId?: string;
36
19
  timeoutMs?: number;
37
20
  profile?: string;
21
+ authorization?: ClientProof;
38
22
  };
39
23
  }
40
24
  export interface PeerRenameFrame {
25
+ authorization?: ClientProof;
41
26
  type: 'peer_rename';
42
27
  id: string;
43
28
  from: string;
@@ -99,7 +84,7 @@ export declare class HubLink {
99
84
  static join(host: string, port: number, token: string, onClose: () => void, onGrant?: (host: string, allow: boolean) => void): Promise<HubLink | null>;
100
85
  isOpen(): boolean;
101
86
  relay(method: WireMethod, params: Record<string, unknown>, opts?: RelayFrame['opts']): Promise<unknown>;
102
- rename(from: string, to: string): Promise<unknown>;
87
+ rename(from: string, to: string, authorization: ClientProof): Promise<unknown>;
103
88
  /** Tell the hub our gate refused `host`, so it can offer the person an Allow. */
104
89
  blocked(host: string, method: string): void;
105
90
  close(): void;
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
4
+ exports.relayError = relayError;
2
5
  /**
3
6
  * src/bridge/peer.ts — let several mcp-browser-extension servers share one Chrome.
4
7
  *
@@ -17,9 +20,6 @@
17
20
  * peer → hub: peer_hello, relay (a wire command), peer_rename, peer_blocked
18
21
  * hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
19
22
  */
20
- Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
22
- exports.relayError = relayError;
23
23
  const ws_1 = require("ws");
24
24
  const protocol_1 = require("../../shared/protocol");
25
25
  const types_1 = require("../executor/types");
@@ -70,6 +70,7 @@ class HubLink {
70
70
  static join(host, port, token, onClose, onGrant) {
71
71
  return new Promise((resolve) => {
72
72
  let settled = false;
73
+ let joined = null;
73
74
  const ws = new ws_1.WebSocket(`ws://${host}:${port}`);
74
75
  const done = (link) => {
75
76
  if (settled)
@@ -97,9 +98,17 @@ class HubLink {
97
98
  catch {
98
99
  return done(null);
99
100
  }
100
- if (frame.type !== 'peer_welcome')
101
- return done(null); // 'unauthorized' or anything else
102
- done(new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose, onGrant));
101
+ if (!joined && frame.type === 'peer_welcome') {
102
+ joined = new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose, onGrant);
103
+ return;
104
+ }
105
+ if (joined && frame.type === 'peer_state' && Array.isArray(frame.profiles)) {
106
+ // The first command needs the extension's signing challenge. Do not
107
+ // report a successful join before the initial browser state arrives.
108
+ joined.profiles = frame.profiles;
109
+ return done(joined);
110
+ }
111
+ done(null); // unauthorized or an incomplete/incompatible handshake
103
112
  };
104
113
  ws.on('message', onFirst);
105
114
  ws.once('open', () => {
@@ -117,8 +126,8 @@ class HubLink {
117
126
  const timeoutMs = (opts?.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS - RELAY_GRACE_MS) + RELAY_GRACE_MS;
118
127
  return this.request({ type: 'relay', id: '', method, params, ...(opts ? { opts } : {}) }, timeoutMs);
119
128
  }
120
- rename(from, to) {
121
- return this.request({ type: 'peer_rename', id: '', from, to }, exports.JOIN_TIMEOUT_MS * 2);
129
+ rename(from, to, authorization) {
130
+ return this.request({ type: 'peer_rename', id: '', from, to, authorization }, exports.JOIN_TIMEOUT_MS * 2);
122
131
  }
123
132
  /** Tell the hub our gate refused `host`, so it can offer the person an Allow. */
124
133
  blocked(host, method) {
@@ -1,18 +1,3 @@
1
- /**
2
- * src/bridge/server.ts — the localhost WebSocket bridge.
3
- *
4
- * The server is the WS SERVER; the extension dials in as the single privileged
5
- * CLIENT. The token is the ONLY trust boundary (the loopback bind is merely
6
- * defense-in-depth; Origin is NOT a gate). Flow:
7
- * 1. Accept any loopback upgrade.
8
- * 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
9
- * otherwise send `unauthorized` and close 4401.
10
- * 3. On success, send `welcome` and file the connection under its profile
11
- * (superseding only that profile's prior one — a security-relevant
12
- * displacement event).
13
- * Other mcp-browser-extension servers (one per Claude session) may join the same port as
14
- * peers with `peer_hello` and relay their commands through us; see ./peer.
15
- */
16
1
  import { type WireEvent, type WireMethod, type WirePolicy } from '../../shared/protocol';
17
2
  /** Where a person gets the extension. */
18
3
  export declare const WEB_STORE_URL: "https://chromewebstore.google.com/detail/mcp-browser-extension/jelfhdlkhbfmlpbghoeaepijllcnplgh";
@@ -29,6 +14,8 @@ export type PairState = 'ok' | 'profile_mismatch' | 'token_mismatch' | 'version_
29
14
  export type ProfileNaming = 'options' | 'auto' | 'legacy';
30
15
  /** One paired browser, as reported by chrome_status. */
31
16
  export interface PairedProfile {
17
+ /** Public, extension-generated replay scope; not a credential. */
18
+ approvalChallenge?: string;
32
19
  name: string;
33
20
  /** options = typed in the extension's Options; auto = assigned by the server
34
21
  * (renamable with profile_rename); legacy = an older extension, "default". */
@@ -86,6 +73,7 @@ export interface BridgeOptions {
86
73
  export type BridgeRole = 'hub' | 'peer';
87
74
  export declare class BridgeServer {
88
75
  private readonly opts;
76
+ private readonly clientIdentity;
89
77
  private wss;
90
78
  /** Profile routing key → its live connection. Multiple browsers stay paired at
91
79
  * once; a command is routed to the connection for its target profile. A new
@@ -167,6 +155,8 @@ export declare class BridgeServer {
167
155
  timeoutMs?: number;
168
156
  profile?: string;
169
157
  }): Promise<unknown>;
158
+ /** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
159
+ private forwardCommand;
170
160
  /** Whether the browser paired as `profile` advertised capability `cap`. A peer
171
161
  * cannot see the hub's handshake, so it answers false (the conservative path). */
172
162
  hasCap(profile: string | undefined, cap: string): boolean;
@@ -201,13 +191,14 @@ export declare class BridgeServer {
201
191
  /** How to pair a browser for `profile` — surfaced whenever that profile has no live connection. */
202
192
  noPairMessage(profile: string): string;
203
193
  /** Every paired browser with how it was named — for chrome_status. */
204
- pairedProfiles(): PairedProfile[];
194
+ pairedProfiles(includeChallenge?: boolean): PairedProfile[];
205
195
  /**
206
196
  * Rename an automatically named browser. The new name sticks across restarts
207
197
  * (it's stored against the extension install); a live connection is re-keyed
208
198
  * in place, so it keeps working without re-pairing.
209
199
  */
210
200
  renameProfile(from: string, to: string): Promise<string>;
201
+ private renameApprovedProfile;
211
202
  status(): {
212
203
  extensionConnected: boolean;
213
204
  port: number;
@@ -1,10 +1,14 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BridgeServer = exports.WEB_STORE_URL = void 0;
4
+ const client_auth_1 = require("../../shared/client-auth");
5
+ const client_identity_1 = require("./client-identity");
2
6
  /**
3
7
  * src/bridge/server.ts — the localhost WebSocket bridge.
4
8
  *
5
9
  * The server is the WS SERVER; the extension dials in as the single privileged
6
- * CLIENT. The token is the ONLY trust boundary (the loopback bind is merely
7
- * defense-in-depth; Origin is NOT a gate). Flow:
10
+ * CLIENT. The token authenticates this transport; the extension separately
11
+ * verifies each command against its approved client keys. Flow:
8
12
  * 1. Accept any loopback upgrade.
9
13
  * 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
10
14
  * otherwise send `unauthorized` and close 4401.
@@ -14,8 +18,6 @@
14
18
  * Other mcp-browser-extension servers (one per Claude session) may join the same port as
15
19
  * peers with `peer_hello` and relay their commands through us; see ./peer.
16
20
  */
17
- Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.BridgeServer = exports.WEB_STORE_URL = void 0;
19
21
  const ws_1 = require("ws");
20
22
  const node_crypto_1 = require("node:crypto");
21
23
  const protocol_1 = require("../../shared/protocol");
@@ -75,6 +77,12 @@ const MAX_PREAUTH_FRAMES = 10;
75
77
  const PORT_WAIT_MS = 4_000;
76
78
  /** Pause between port-bind retries while waiting for the old listener to exit. */
77
79
  const PORT_RETRY_MS = 250;
80
+ /** Bridge frames are small JSON control messages; cap unauthenticated memory use. */
81
+ const MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
82
+ /** Native clients omit Origin; Chrome extensions use their own isolated origin. */
83
+ function allowBridgeOrigin(origin) {
84
+ return origin === undefined || /^chrome-extension:\/\/[a-p]{32}$/.test(origin);
85
+ }
78
86
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
79
87
  /** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
80
88
  function portBusyMessage(host, port) {
@@ -88,6 +96,7 @@ function portBusyMessage(host, port) {
88
96
  }
89
97
  class BridgeServer {
90
98
  opts;
99
+ clientIdentity = (0, client_identity_1.createClientIdentity)(() => 'Local browser client');
91
100
  wss = null;
92
101
  /** Profile routing key → its live connection. Multiple browsers stay paired at
93
102
  * once; a command is routed to the connection for its target profile. A new
@@ -295,7 +304,13 @@ class BridgeServer {
295
304
  /** One bind attempt. Resolves with a listening server or rejects with the listen error. */
296
305
  listenOnce(host, port) {
297
306
  return new Promise((resolve, reject) => {
298
- const wss = new ws_1.WebSocketServer({ host, port });
307
+ const wss = new ws_1.WebSocketServer({
308
+ host,
309
+ port,
310
+ maxPayload: MAX_BRIDGE_FRAME_BYTES,
311
+ perMessageDeflate: false,
312
+ verifyClient: ({ origin }) => allowBridgeOrigin(origin || undefined),
313
+ });
299
314
  const onError = (err) => {
300
315
  wss.off('listening', onListening);
301
316
  // Close so the failed server doesn't linger and leak a handle on retry.
@@ -365,12 +380,33 @@ class BridgeServer {
365
380
  * Rejects with an actionable message if that profile has no live browser.
366
381
  */
367
382
  async sendCommand(method, params, opts) {
383
+ const profile = routeKey(opts?.profile);
384
+ const timeoutMs = opts?.timeoutMs ?? (0, connection_1.defaultTimeoutFor)(method);
385
+ const intent = { v: protocol_1.PROTOCOL_VERSION, method, params, tabId: opts?.tabId, timeoutMs };
386
+ const challenge = this.hub
387
+ ? this.hub.profiles.find((p) => p.name === profile)?.approvalChallenge
388
+ : this.conns.get(profile)?.approvalChallenge;
389
+ let authorization;
390
+ if (!(0, client_auth_1.isHealthProbe)(intent)) {
391
+ if (!this.hasConnection(profile))
392
+ throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
393
+ if (!challenge)
394
+ throw new types_1.ExecutorError('POLICY_DENIED', 'Update the extension and MCP server together: this browser cannot enforce client approval yet.');
395
+ authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
396
+ }
397
+ const options = { ...opts, timeoutMs, authorization };
368
398
  if (this.hub)
369
- return this.hub.relay(method, params, opts);
399
+ return this.hub.relay(method, params, options);
400
+ return this.forwardCommand(method, params, options);
401
+ }
402
+ /** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
403
+ async forwardCommand(method, params, opts) {
370
404
  const profile = routeKey(opts?.profile);
371
405
  const conn = this.conns.get(profile);
372
- if (!conn || !conn.isOpen()) {
406
+ if (!conn?.isOpen())
373
407
  throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
408
+ if (!(0, client_auth_1.isHealthProbe)({ method, params }) && (!conn.hasCap(client_auth_1.CLIENT_APPROVAL_CAP) || !opts?.authorization)) {
409
+ throw new types_1.ExecutorError('POLICY_DENIED', 'Signed client authorization and an updated extension are required.');
374
410
  }
375
411
  return conn.sendCommand(method, params, opts);
376
412
  }
@@ -478,19 +514,18 @@ class BridgeServer {
478
514
  return steps.length > 0 ? `${head} ${steps.join(' ')}` : head;
479
515
  }
480
516
  /** Every paired browser with how it was named — for chrome_status. */
481
- pairedProfiles() {
517
+ pairedProfiles(includeChallenge = false) {
482
518
  if (this.hub)
483
- return [...this.hub.profiles];
519
+ return this.hub.profiles.map(({ approvalChallenge, ...p }) => includeChallenge ? { ...p, approvalChallenge } : p);
484
520
  const out = [];
485
521
  for (const [name, conn] of this.conns) {
486
522
  if (!conn.isOpen())
487
523
  continue;
488
- const activeUrl = conn.lastActiveUrl(Number.POSITIVE_INFINITY) ?? undefined;
489
524
  const o = this.origins.get(name);
490
525
  out.push({
491
526
  name,
492
527
  naming: o?.naming ?? 'legacy',
493
- ...(activeUrl ? { activeUrl } : {}),
528
+ ...(includeChallenge && conn.approvalChallenge ? { approvalChallenge: conn.approvalChallenge } : {}),
494
529
  ...(o?.extVersion ? { extVersion: o.extVersion } : {}),
495
530
  ...(o?.extId || o?.install ? { extSource: extensionSource(o) } : {}),
496
531
  });
@@ -503,8 +538,18 @@ class BridgeServer {
503
538
  * in place, so it keeps working without re-pairing.
504
539
  */
505
540
  async renameProfile(from, to) {
506
- if (this.hub)
507
- return String(await this.hub.rename(from, to));
541
+ if (this.hub) {
542
+ const challenge = this.hub.profiles.find((p) => p.name === routeKey(from))?.approvalChallenge;
543
+ if (!challenge)
544
+ throw new types_1.ExecutorError('POLICY_DENIED', 'Connect an updated browser before renaming it.');
545
+ const intent = { v: protocol_1.PROTOCOL_VERSION, method: 'ping_probe', params: { requireClientApproval: true, renameProfile: { from, to } }, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe') };
546
+ const authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
547
+ return String(await this.hub.rename(from, to, authorization));
548
+ }
549
+ await this.sendCommand('ping_probe', { requireClientApproval: true, renameProfile: { from, to } }, { profile: from });
550
+ return this.renameApprovedProfile(from, to);
551
+ }
552
+ async renameApprovedProfile(from, to) {
508
553
  const src = routeKey(from);
509
554
  const live = this.conns.get(src);
510
555
  if (live?.isOpen() && this.origins.get(src)?.naming === 'options') {
@@ -600,6 +645,7 @@ class BridgeServer {
600
645
  this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, profile, Array.isArray(frame.caps) ? frame.caps : undefined, {
601
646
  naming,
602
647
  installId,
648
+ approvalChallenge: typeof frame.approvalChallenge === 'string' && /^[a-f0-9-]{36}$/.test(frame.approvalChallenge) ? frame.approvalChallenge : undefined,
603
649
  ...(typeof frame.ext?.version === 'string' ? { extVersion: frame.ext.version.slice(0, 32) } : {}),
604
650
  ...(typeof frame.ext?.id === 'string' ? { extId: frame.ext.id.slice(0, 64) } : {}),
605
651
  ...(typeof frame.install === 'string' ? { install: frame.install.slice(0, 32) } : {}),
@@ -634,7 +680,7 @@ class BridgeServer {
634
680
  this.peers.add(ws);
635
681
  this.log(`mcp-browser-extension pid ${pid ?? '?'} joined as a peer (${this.peers.size} sharing these browsers)`);
636
682
  this.sendPeer(ws, { type: 'peer_welcome', hubPid: process.pid });
637
- this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles() });
683
+ this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles(true) });
638
684
  ws.on('message', (raw) => this.handlePeerFrame(ws, raw.toString()));
639
685
  ws.on('close', () => this.peers.delete(ws));
640
686
  ws.on('error', () => {
@@ -653,9 +699,9 @@ class BridgeServer {
653
699
  work.then((data) => this.sendPeer(ws, { type: 'relay_result', id, ok: true, data }), (err) => this.sendPeer(ws, (0, peer_1.relayError)(id, err)));
654
700
  };
655
701
  if (frame.type === 'relay')
656
- answer(frame.id, this.sendCommand(frame.method, frame.params ?? {}, frame.opts));
702
+ answer(frame.id, this.forwardCommand(frame.method, frame.params ?? {}, frame.opts));
657
703
  else if (frame.type === 'peer_rename')
658
- answer(frame.id, this.renameProfile(frame.from, frame.to));
704
+ answer(frame.id, this.forwardCommand('ping_probe', { requireClientApproval: true, renameProfile: { from: frame.from, to: frame.to } }, { profile: frame.from, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe'), authorization: frame.authorization }).then(() => this.renameApprovedProfile(frame.from, frame.to)));
659
705
  else if (frame.type === 'peer_blocked' && typeof frame.host === 'string')
660
706
  this.noteBlocked(frame.host, String(frame.method ?? ''));
661
707
  }
@@ -663,7 +709,7 @@ class BridgeServer {
663
709
  broadcastState() {
664
710
  if (this.peers.size === 0)
665
711
  return;
666
- const state = { type: 'peer_state', profiles: this.pairedProfiles() };
712
+ const state = { type: 'peer_state', profiles: this.pairedProfiles(true) };
667
713
  for (const peer of this.peers)
668
714
  this.sendPeer(peer, state);
669
715
  }
@@ -709,6 +755,7 @@ class BridgeServer {
709
755
  sessionId,
710
756
  heartbeatMs: this.heartbeatMs,
711
757
  caps,
758
+ approvalChallenge: origin.approvalChallenge,
712
759
  onEvent: this.opts.onEvent,
713
760
  onSiteGrant: (host, allow) => this.applySiteGrant(host, allow),
714
761
  onLog: (m) => this.log(m),
@@ -12,8 +12,7 @@
12
12
  *
13
13
  * Grants only ever ADD to the flags' list, and only grants can be removed again:
14
14
  * a site that came from `--allow-domain` stays until the flags change. A grant
15
- * is always one concrete host (or `*.host`), never the `*` catch-all — allowing
16
- * every site stays a deliberate flag (`--unsafe-all-domains`).
15
+ * is always one concrete host (or `*.host`), never an all-site catch-all.
17
16
  */
18
17
  export declare const GRANTS_FILE = "allowed-sites.json";
19
18
  /**
@@ -13,8 +13,7 @@
13
13
  *
14
14
  * Grants only ever ADD to the flags' list, and only grants can be removed again:
15
15
  * a site that came from `--allow-domain` stays until the flags change. A grant
16
- * is always one concrete host (or `*.host`), never the `*` catch-all — allowing
17
- * every site stays a deliberate flag (`--unsafe-all-domains`).
16
+ * is always one concrete host (or `*.host`), never an all-site catch-all.
18
17
  */
19
18
  Object.defineProperty(exports, "__esModule", { value: true });
20
19
  exports.SiteGrants = exports.GRANTS_FILE = void 0;
@@ -59,7 +58,9 @@ class SiteGrants {
59
58
  constructor(allowDomains, dataDir) {
60
59
  this.allowDomains = allowDomains;
61
60
  this.dataDir = dataDir;
62
- this.base = new Set(allowDomains.map((d) => d.toLowerCase()));
61
+ const namedSites = allowDomains.map(policy_1.normalizeDomainPattern).filter(Boolean);
62
+ allowDomains.splice(0, allowDomains.length, ...namedSites);
63
+ this.base = new Set(namedSites);
63
64
  for (const site of this.load())
64
65
  this.add(site);
65
66
  }
package/dist/src/cli.js CHANGED
@@ -400,6 +400,7 @@ async function main() {
400
400
  userDataDir: workspace.profileDir,
401
401
  downloadDir: workspace.downloadDir,
402
402
  headless: cfg.headless,
403
+ policy: (0, policy_1.toWirePolicy)(cfg.policy),
403
404
  },
404
405
  }),
405
406
  });
@@ -526,9 +527,6 @@ async function serveRemote(cfg, dataDir, stopBridge) {
526
527
  'Anyone with this URL can drive this Chrome within the allowed sites. Keep it private.',
527
528
  'Cut everyone off: restart with --rotate-remote-key.',
528
529
  ...(tunnel ? ['The tunnel URL changes every time this starts.'] : []),
529
- ...(cfg.policy.allowDomains.includes('*')
530
- ? ['', 'WARNING: --unsafe-all-domains is on, so remote clients can reach every site you are signed in to.']
531
- : []),
532
530
  '',
533
531
  ];
534
532
  process.stdout.write(`${lines.join('\n')}\n`);
@@ -20,7 +20,8 @@ const node_os_1 = require("node:os");
20
20
  const node_path_1 = require("node:path");
21
21
  const node_fs_1 = require("node:fs");
22
22
  const protocol_1 = require("../shared/protocol");
23
- const policy_1 = require("./security/policy");
23
+ const policy_1 = require("../shared/policy");
24
+ const policy_2 = require("./security/policy");
24
25
  const compat_1 = require("./compat");
25
26
  /** Local port for `--http` (one above the bridge's). */
26
27
  exports.DEFAULT_HTTP_PORT = 38018;
@@ -131,8 +132,7 @@ function parseArgs(argv) {
131
132
  policyFile = readPolicyFile(requireValue(argv[++i], '--policy'));
132
133
  break;
133
134
  case '--unsafe-all-domains':
134
- policyFlags.allowDomains = ['*'];
135
- break;
135
+ throw new Error('All-site access is no longer supported. Remove --unsafe-all-domains from your MCP configuration and approve websites in the Chrome extension popup.');
136
136
  case '--allow-domain':
137
137
  (policyFlags.allowDomains ??= []).push(requireValue(argv[++i], '--allow-domain'));
138
138
  break;
@@ -157,6 +157,9 @@ function parseArgs(argv) {
157
157
  case '--enable-observers':
158
158
  policyFlags.allowObservers = true;
159
159
  break;
160
+ case '--enable-cookie-values':
161
+ policyFlags.allowCookieValues = true;
162
+ break;
160
163
  case '--redact':
161
164
  policyFlags.redact = true;
162
165
  break;
@@ -244,7 +247,13 @@ function parseArgs(argv) {
244
247
  throw new Error('--no-email-verification cannot be used with --tunnel');
245
248
  }
246
249
  // File first, then flags win.
247
- const policy = (0, policy_1.resolvePolicy)({ ...policyFile, ...policyFlags });
250
+ for (const source of [policyFile, policyFlags]) {
251
+ if (source?.allowDomains !== undefined && (!Array.isArray(source.allowDomains) ||
252
+ source.allowDomains.some((domain) => typeof domain !== 'string' || !(0, policy_1.normalizeDomainPattern)(domain)))) {
253
+ throw new Error('Each allowed site must name a website. All-site access is no longer supported; approve websites in the Chrome extension popup.');
254
+ }
255
+ }
256
+ const policy = (0, policy_2.resolvePolicy)({ ...policyFile, ...policyFlags });
248
257
  // Uploads must be confined to a directory — refuse to start with uploads enabled
249
258
  // but no dir, rather than silently allow unrestricted local-file access.
250
259
  if (policy.allowUploads && !policy.uploadsDir) {
@@ -408,7 +417,6 @@ Security (default: deny-all safe mode):
408
417
  to <data-dir>/allowed-sites.json; no restart needed)
409
418
  --no-site-grants Ignore sites allowed from the extension's Options page; the
410
419
  allowlist is exactly what these flags and --policy say
411
- --unsafe-all-domains Allow every domain (loud footgun)
412
420
  --enable-mutations Enable click/type/navigate/… (off by default)
413
421
  --unsafe-enable-eval Enable the eval primitive (off by default)
414
422
  --enable-downloads Enable download_file (off by default)
@@ -419,6 +427,9 @@ Security (default: deny-all safe mode):
419
427
  in-page hook on allowlisted sites that records console
420
428
  output, fetch/XHR traffic, and intercepts alert/confirm/
421
429
  prompt (off by default: it patches page globals).
430
+ --enable-cookie-values Let get_cookies return cookie values. Off by default:
431
+ a session cookie is the login itself, so get_cookies
432
+ returns names and flags with every value hidden.
422
433
  --redact Scrub secret-shaped strings (JWTs, cloud keys, bearer
423
434
  tokens, private keys) out of page reads. Password field
424
435
  values are always suppressed, with or without this.
@@ -12,6 +12,7 @@
12
12
  * stealth init on the launch path only, and tab resolution. `tabId`s are stamped
13
13
  * `cdp:<sessionId>:<n>` so a handle never mis-routes across a backend switch.
14
14
  */
15
+ import type { WirePolicy } from '../../shared/protocol';
15
16
  import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
16
17
  export interface CdpOptions {
17
18
  mode: 'connect' | 'launch';
@@ -19,6 +20,8 @@ export interface CdpOptions {
19
20
  userDataDir: string;
20
21
  headless?: boolean;
21
22
  downloadDir?: string;
23
+ /** The same URL/method policy enforced by the MCP tool layer. */
24
+ policy?: WirePolicy;
22
25
  }
23
26
  export declare class CdpExecutor implements Executor {
24
27
  private readonly opts;
@@ -21,6 +21,7 @@ const node_path_1 = require("node:path");
21
21
  const node_crypto_1 = require("node:crypto");
22
22
  const playwright_1 = require("playwright");
23
23
  const download_1 = require("../../shared/download");
24
+ const policy_1 = require("../../shared/policy");
24
25
  const snapshot_1 = require("../../shared/snapshot");
25
26
  const screenshot_1 = require("../../shared/screenshot");
26
27
  const types_1 = require("./types");
@@ -29,6 +30,7 @@ const STEALTH = `Object.defineProperty(navigator,'webdriver',{get:()=>undefined}
29
30
  const NON_CONTENT = /^(file|data|devtools|chrome|about):/i;
30
31
  /** Playwright errors that mean the page/context/browser died under us → DETACHED. */
31
32
  const CLOSED_TARGET = /Target closed|Browser has been closed|Target page, context or browser has been closed|Execution context was destroyed/i;
33
+ const MAX_CDP_DOWNLOAD_BYTES = 50 * 1024 * 1024;
32
34
  /** Minimal CSS attribute-value escape for ref selectors (refs are `e\d+`, but be safe). */
33
35
  function cssEscape(s) {
34
36
  return s.replace(/["\\]/g, '\\$&');
@@ -587,38 +589,80 @@ class CdpExecutor {
587
589
  const ctx = await this.getContext();
588
590
  const dir = this.opts.downloadDir ?? (0, node_path_1.join)(this.opts.userDataDir, 'downloads');
589
591
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
590
- const safeName = (0, download_1.sanitizeDownloadName)(args.suggestedName);
591
- const dest = (0, node_path_1.join)(dir, safeName);
592
- if (args.url) {
593
- const resp = await ctx.request.get(args.url);
594
- const body = await resp.body();
595
- if (!(0, download_1.isWithinSizeCap)(body.length)) {
596
- throw new types_1.ExecutorError('DOWNLOAD_FAILED', `download exceeds the ${download_1.MAX_DOWNLOAD_BYTES}-byte cap`);
592
+ let url = args.url ?? '';
593
+ let suggestedName = args.suggestedName;
594
+ if (!url) {
595
+ const p = await this.resolveTab(args.tabId);
596
+ const target = args.target;
597
+ if (!target)
598
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'provide a url or a target');
599
+ const locator = this.locator(p, target);
600
+ const href = await locator.getAttribute('href');
601
+ if (!href)
602
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'the target must be a link with an href');
603
+ const baseUrl = await locator.evaluate((el) => el.ownerDocument.baseURI);
604
+ try {
605
+ url = new URL(href, baseUrl).href;
597
606
  }
598
- (0, node_fs_1.writeFileSync)(dest, body);
599
- return { path: dest, backend: this.backend, bytes: body.length, mimeType: resp.headers()['content-type'] };
607
+ catch {
608
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'the target has an invalid download URL');
609
+ }
610
+ suggestedName = (await locator.getAttribute('download')) || suggestedName;
600
611
  }
601
- // Element-triggered download.
602
- const p = await this.resolveTab(args.tabId);
603
- const target = args.target;
604
- if (!target)
605
- throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'provide a url or a target');
606
- const [dl] = await Promise.all([p.waitForEvent('download'), this.locator(p, target).click()]);
607
- let bytes = 0;
612
+ const policy = this.opts.policy ?? { allowDomains: [], allowEval: false, allowDownloads: false, allowUploads: false, allowAllTabs: false, enableMutations: false, allowObservers: false };
613
+ const verdict = (0, policy_1.evaluatePolicy)(url, 'download_file', policy);
614
+ if (!verdict.ok)
615
+ throw new types_1.ExecutorError('POLICY_DENIED', verdict.reason);
616
+ let response;
608
617
  try {
609
- // Surface a cancelled/interrupted download instead of reporting a phantom success.
610
- const failure = await dl.failure();
611
- if (failure)
612
- throw new types_1.ExecutorError('DOWNLOAD_FAILED', `download failed: ${failure}`);
613
- await dl.saveAs(dest);
614
- bytes = (0, node_fs_1.statSync)(dest).size;
618
+ // Node fetch has no browser cookie jar; error mode refuses every redirect
619
+ // before any intermediate destination is contacted.
620
+ response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(120_000) });
615
621
  }
616
- catch (err) {
617
- if (err instanceof types_1.ExecutorError)
622
+ catch {
623
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'download request failed or redirects are not supported');
624
+ }
625
+ if (!response.ok) {
626
+ await response.body?.cancel().catch(() => undefined);
627
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', `download returned HTTP ${response.status}`);
628
+ }
629
+ const declaredLength = Number(response.headers.get('content-length'));
630
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_CDP_DOWNLOAD_BYTES) {
631
+ await response.body?.cancel().catch(() => undefined);
632
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'download exceeds the 50 MiB limit');
633
+ }
634
+ const chunks = [];
635
+ let totalBytes = 0;
636
+ const reader = response.body?.getReader();
637
+ if (reader) {
638
+ try {
639
+ while (true) {
640
+ const { done, value } = await reader.read();
641
+ if (done)
642
+ break;
643
+ totalBytes += value.byteLength;
644
+ if (totalBytes > MAX_CDP_DOWNLOAD_BYTES) {
645
+ await reader.cancel();
646
+ throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'download exceeds the 50 MiB limit');
647
+ }
648
+ chunks.push(value);
649
+ }
650
+ }
651
+ catch (err) {
652
+ await reader.cancel().catch(() => undefined);
618
653
  throw err;
654
+ }
655
+ }
656
+ const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes);
657
+ const safeName = (0, download_1.sanitizeDownloadName)(suggestedName);
658
+ const dest = (0, node_path_1.join)(dir, safeName);
659
+ try {
660
+ (0, node_fs_1.writeFileSync)(dest, body);
661
+ }
662
+ catch (err) {
619
663
  throw new types_1.ExecutorError('DOWNLOAD_FAILED', `failed to save download: ${err.message}`);
620
664
  }
621
- return { path: dest, backend: this.backend, bytes, suggestedName: dl.suggestedFilename() };
665
+ return { path: dest, backend: this.backend, bytes: body.length, mimeType: response.headers.get('content-type') ?? undefined, suggestedName: safeName };
622
666
  }
623
667
  async uploadFile(t, files, opts) {
624
668
  return this.guard(async () => {
@@ -36,16 +36,18 @@ const SERVER_VERSION = '0.1.0';
36
36
  * than retry.
37
37
  */
38
38
  exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, signed-in Chrome. " +
39
- 'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
39
+ 'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you, then click Verify this device on the website. If it returns ACCESS_INPUT_REQUIRED, the local verification form opens automatically in the browser; also show its URL as a backup so the user can enter their email. Do not open the backup URL again or add another chat email prompt while that form is active. Do not fill it or read their email for them. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
40
+ 'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
40
41
  'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
41
42
  'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
42
43
  'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s ' +
43
- 'toolbar icon (its popup has an Allow button). It applies at once, with no restart. You cannot approve a site yourself. ' +
44
+ 'toolbar icon (its popup has an Allow button). Relay the friendly one-line approval steps from the error and wait for the person. It applies at once, with no restart. You cannot approve a site yourself or enable all-site access. ' +
44
45
  "Work in your own tab: tab_new opens one in the background, and calls without a tabId go to it, never to the tab the user is on. " +
45
46
  "To act on the user's current page, pass its tabId from tabs_list.";
46
47
  const LOCAL_BYPASS_INSTRUCTIONS = 'Email verification is disabled by the local operator for this server. Only trusted local MCP clients should connect; the browser-domain allowlist and other capability safety gates remain active. ' +
48
+ 'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
47
49
  'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), give the user the steps in its `setup` field. ' +
48
- 'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s toolbar icon. ' +
50
+ 'If a tool fails with POLICY_DENIED because a site is not allowed, relay its friendly one-line approval steps and wait for the person to allow it from the extension\'s toolbar icon. Never approve it yourself or enable all-site access. ' +
49
51
  "Work in your own tab: tab_new opens one in the background. To act on the user's current page, pass its tabId from tabs_list.";
50
52
  /** Default version reported when no explicit version is passed in (legacy callers/tests). */
51
53
  const DEFAULT_VERSION = SERVER_VERSION;
@@ -1,15 +1,3 @@
1
- /**
2
- * src/mcp/tools.ts — the MCP tool surface: the advertised catalog
3
- * (`TOOL_DEFINITIONS`, each with a zod `inputSchema`), the name→handler dispatch
4
- * (`TOOL_HANDLERS`), the never-throw firewall (`dispatchToolCall`), and
5
- * `registerTools()` which registers every tool on an `McpServer` via
6
- * `registerTool` — the SDK validates the zod schema before dispatch runs.
7
- *
8
- * Each handler: validate args → **policy-gate against the relevant URL** → call
9
- * the active Executor (or a server-side helper) → serialize via an envelope.
10
- * Nothing here throws to the transport: `dispatchToolCall` renders any thrown
11
- * `Error` as an `isError` result.
12
- */
13
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
2
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
15
3
  import { z } from 'zod';