mcp-browser-extension 0.9.31 → 0.9.33

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 (45) hide show
  1. package/README.md +58 -10
  2. package/SETUP.md +27 -3
  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/src/access-browser.js +66 -23
  16. package/dist/src/access.js +2 -1
  17. package/dist/src/bridge/client-identity.d.ts +10 -0
  18. package/dist/src/bridge/client-identity.js +28 -0
  19. package/dist/src/bridge/connection.d.ts +5 -0
  20. package/dist/src/bridge/connection.js +4 -12
  21. package/dist/src/bridge/peer.d.ts +4 -19
  22. package/dist/src/bridge/peer.js +17 -8
  23. package/dist/src/bridge/server.d.ts +7 -16
  24. package/dist/src/bridge/server.js +64 -17
  25. package/dist/src/bridge/site-grants.d.ts +1 -2
  26. package/dist/src/bridge/site-grants.js +4 -3
  27. package/dist/src/cli.js +1 -3
  28. package/dist/src/config.js +10 -5
  29. package/dist/src/executor/cdp-executor.d.ts +3 -0
  30. package/dist/src/executor/cdp-executor.js +69 -25
  31. package/dist/src/mcp/server.js +5 -3
  32. package/dist/src/mcp/tools.d.ts +0 -12
  33. package/dist/src/mcp/tools.js +35 -20
  34. package/dist/src/remote/key.js +12 -2
  35. package/dist/src/security/policy.js +1 -1
  36. package/docs/BLUEPRINT.md +6 -1
  37. package/docs/CLIENT-APPROVAL.md +150 -0
  38. package/extension-dist/activity-dashboard.css +43 -0
  39. package/extension-dist/background.js +953 -58
  40. package/extension-dist/manifest.json +10 -9
  41. package/extension-dist/options.html +101 -32
  42. package/extension-dist/options.js +558 -70
  43. package/extension-dist/popup.html +164 -90
  44. package/extension-dist/popup.js +576 -79
  45. package/package.json +3 -2
package/README.md CHANGED
@@ -63,6 +63,12 @@ for one), so the question is what you get on top of the session:
63
63
  keys and JWTs out of page reads.
64
64
  - **An audit trail**: every call is logged with the URL, the allow/deny
65
65
  verdict, duration and bytes returned.
66
+ - **Recent activity in Chrome**: the popup shows the latest five activity rows (a tab's open/close pair shares one row);
67
+ Settings shows up to 100 with outcome filters. This session history contains
68
+ website names and app keys, without page text, entered values or full URLs.
69
+ - **Stop access**: disconnect all apps from the popup or Settings. The stop
70
+ survives restarts; Resume connection requires fresh approval for each app.
71
+ A command already running may finish, and completed actions cannot be undone.
66
72
  - **It stays out of your tab**: the agent opens its own tabs in the background
67
73
  and works there, so the page you are on is never navigated, clicked or pulled
68
74
  out of focus. `batch` reads many of them at once in one call.
@@ -138,14 +144,15 @@ with `/mcp` inside a session — no restart needed.
138
144
 
139
145
  By default everything is **deny-all** (no domains, no eval, no mutations). Grant
140
146
  exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
141
- `--enable-downloads`, `--enable-uploads`, `--unsafe-enable-eval`, or `--unsafe-all-domains`.
147
+ `--enable-downloads`, `--enable-uploads`, or `--unsafe-enable-eval`. All-site access
148
+ is not supported; approve named websites in the Chrome extension popup.
142
149
 
143
150
  Email verification is on by default. For a trusted, local unattended setup, you
144
151
  can opt out with `--no-email-verification` (or set
145
152
  `MCP_BROWSER_EXTENSION_EMAIL_VERIFICATION=0`). This skips the email prompt and
146
153
  the Firebase access service, so no email or device grant is sent there. The
147
- local MCP client can then use the browser within the configured domain and
148
- capability policy without an account check. Treat every process that can launch
154
+ local MCP client still needs approval inside Chrome before using the extension,
155
+ and stays within the configured domain and capability policy. Treat every process that can launch
149
156
  or reach this local server as trusted. This option is rejected with `--tunnel`
150
157
  and does not turn off telemetry or the npm update check.
151
158
 
@@ -155,7 +162,7 @@ and does not turn off telemetry or the npm update check.
155
162
  > with `--uploads-dir <path>` to restrict uploads to files inside that directory
156
163
  > (`..` traversal is blocked) — strongly recommended for unattended use.
157
164
 
158
- **Pair once, never again.** Both examples above include `--persist-token`, which
165
+ **Keep transport pairing across restarts.** Both examples above include `--persist-token`, which
159
166
  is what makes the pairing survive a restart — drop it if you'd rather have the
160
167
  stricter default described next.
161
168
 
@@ -163,7 +170,8 @@ Without `--persist-token` a fresh token is minted every boot (the secure
163
170
  default), which means re-pairing the extension on each restart. With it, the
164
171
  token is stored 0600 at `~/.mcp-browser-extension/token` and reused; the extension's
165
172
  keepalive auto-reconnects with no manual step. `MCP_BROWSER_EXTENSION_TOKEN` pins the token
166
- explicitly (and is never written to disk).
173
+ explicitly (and is never written to disk). This transport pairing does not grant
174
+ an app access: every new app session has a separate key and needs Chrome approval.
167
175
 
168
176
  **2. Install the extension** — **required**; the server can drive nothing without it.
169
177
 
@@ -202,6 +210,44 @@ token. Chrome lets only this extension talk to that helper. If the server ever
202
210
  changes its token, the extension fetches the new one by itself. Nothing to copy
203
211
  or paste.
204
212
 
213
+ **5. Approve the app connection in Chrome.** Run `chrome_status`, then open
214
+ the extension popup's **App connections** section. Compare the key
215
+ fingerprint with the one in the tool result and click **Approve for 8 hours**.
216
+ App names are supplied by the app; the key identifies this particular session.
217
+ Retry the tool after approving. A question-mark badge and the popup show pending
218
+ requests, even if the client hides its prompts. Approve, deny, revoke and restore
219
+ connections directly in the popup or in Settings.
220
+
221
+ Each app session has its own signing key, held only in that server session's memory.
222
+ Approval applies to this Chrome profile for eight hours. Restarting the app's MCP
223
+ connection creates a new key and needs a new approval. **Remove access** blocks further
224
+ commands from that key; an action already running may finish. YOLO, bypass-permission
225
+ mode and `--no-email-verification` cannot skip this extension check.
226
+
227
+ **6. Allow the websites you need.** The popup shows a Website access request.
228
+ Click **Allow**, then accept Chrome's website permission prompt if shown. This
229
+ single flow grants that named website in both Chrome and the server's allowlist.
230
+ Allowing a website lets approved apps read and take actions there, including
231
+ sending messages, deleting information and making purchases, subject to the
232
+ configured tool permissions. There is no separate extension approval for each
233
+ action. Website access is remembered until removed and shared by approved app
234
+ sessions; each app still needs its own unexpired connection approval.
235
+ Removing the website also removes its Chrome permission. Existing server flags
236
+ do not grant Chrome access, and an upgrade may ask you to approve websites again.
237
+
238
+ Cookies and downloads ask separately under **Extra permissions** when needed.
239
+ They start off, and their buttons also let you remove permission. Downloads are
240
+ fetched without browser cookies, redirects are refused, and files are limited to
241
+ 50 MiB. Links that require a signed-in browser session or redirect may not
242
+ work. Proxy
243
+ authentication and IP lookups request permission from Settings. Chrome does not
244
+ support making `debugger` or `proxy` optional; those remain required, and debugger
245
+ commands still check website permission before they run.
246
+
247
+ Update both the extension and server before using client approval. An older
248
+ extension cannot enforce it, and the updated server refuses browser commands
249
+ until the extension is updated. See [client approval details](docs/CLIENT-APPROVAL.md).
250
+
205
251
  <details>
206
252
  <summary>Developers: load it unpacked instead</summary>
207
253
 
@@ -232,7 +278,8 @@ stays in the toolbar. Hover it for the status in words.
232
278
 
233
279
  | Badge | Meaning |
234
280
  |---|---|
235
- | green dot | paired and connected |
281
+ | green dot | transport paired and connected |
282
+ | question mark | a connection or site needs your review |
236
283
  | yellow dots | connecting |
237
284
  | grey circle | not paired yet (no server has run, or no pairing file) |
238
285
  | red exclamation mark | token rejected; the server rotated it, re-pairs by itself in a moment |
@@ -291,8 +338,9 @@ and the rest join the new hub. A call that was in flight at that moment fails
291
338
  once with `EXTENSION_DISCONNECTED` and is retried automatically when it is safe
292
339
  to repeat.
293
340
 
294
- Peers authenticate with the pairing token from the 0600 handshake file, so only
295
- your own OS user can join. A server too old to share the port is replaced as
341
+ Peers authenticate to the local transport with the pairing token from the 0600
342
+ handshake file. That token alone cannot authorize browser commands: each session
343
+ must sign its own commands with a key approved inside Chrome. A server too old to share the port is replaced as
296
344
  before: it is verified to be an MCP Browser Extension server, then stopped. Anything that isn't a
297
345
  verified MCP Browser Extension server is never touched — a port held by some other program is
298
346
  reported, never killed.
@@ -665,7 +713,6 @@ status badge, and a stable pairing token (`--persist-token`).
665
713
  ```
666
714
  mcp-browser-extension --allow-domain example.com --enable-mutations
667
715
  mcp-browser-extension --policy ./policy.json # see policy.example.json
668
- mcp-browser-extension --unsafe-all-domains # loud footgun
669
716
  mcp-browser-extension --enable-observers # console/network/dialog capture (patches page globals)
670
717
  mcp-browser-extension --redact # scrub secret-shaped strings out of page reads
671
718
  ```
@@ -677,7 +724,8 @@ is allowed (with its own Allow), and lists the allowed sites. The Options page
677
724
  (Settings in the popup) also has a box to allow any other site. An allowed site applies at once to every session sharing that Chrome, and
678
725
  is saved to `~/.mcp-browser-extension/allowed-sites.json` so it survives restarts. Sites
679
726
  allowed there can be removed there; sites from `--allow-domain` stay until the
680
- flags change. The catch-all `*` can only come from `--unsafe-all-domains`. The
727
+ flags change. Catch-all patterns do not grant access. Remove the retired
728
+ `--unsafe-all-domains` option from older configurations before starting. The
681
729
  agent cannot approve a site itself: MCP Browser Extension never reads or drives the
682
730
  extension's own pages. Run with `--no-site-grants` to keep the allowlist exactly
683
731
  what the flags say.
package/SETUP.md CHANGED
@@ -14,8 +14,10 @@ inside Chrome's own UI (loading the extension, pasting the pairing token).
14
14
  fix it. Do not proceed on a broken step.
15
15
  - The server is **deny-all by default**: no domains, no clicks, no eval, no
16
16
  downloads, no uploads. Ask the human which domains they want to automate and
17
- grant only those with `--allow-domain`. Never reach for `--unsafe-all-domains`
18
- or `--unsafe-enable-eval` unless the human asks for them by name.
17
+ grant only those with `--allow-domain`, or let the person allow them from the
18
+ Chrome extension popup. All-site access is not supported. Remove
19
+ `--unsafe-all-domains` from older configurations; never replace it with a
20
+ catch-all pattern. Only enable `--unsafe-enable-eval` if the human asks by name.
19
21
  - Never print or log the pairing token. It lives in a 0600 file; show the human
20
22
  its path, not its contents.
21
23
  - Requires Node 18 or newer (`node --version`) and Chrome 116 or newer.
@@ -140,14 +142,23 @@ If the client suppresses or cannot display the prompt, or it is dismissed,
140
142
  the server automatically opens a temporary local browser form and returns
141
143
  `ACCESS_INPUT_REQUIRED` with its link. Show the link as a backup if the form
142
144
  did not appear on the computer running the server.
145
+ Do not open that link again or ask for the email in chat while the browser form
146
+ is active. An accepted chat email prompt does not open a browser input form;
147
+ repeated tool calls reuse the active prompt or pending verification.
143
148
  The human enters their email, opens the sign-in link from their inbox, and clicks
144
149
  **Verify this device**. Do not fill
145
150
  the form or open their email for them. The form expires after 10 minutes.
146
151
  After they submit it, call `chrome_status` to wait for verification.
147
152
  YOLO/bypass permission mode does not skip email verification.
148
153
 
154
+ Website access also needs Chrome permission. Relay the tool's one-line Allow
155
+ instructions; the person clicks Allow in the popup and accepts Chrome's native
156
+ prompt if shown. A server allow-domain flag does not supply this permission.
157
+ For a blocked optional feature, relay the Extra permissions instructions. Never
158
+ request or grant these permissions through browser automation.
159
+
149
160
  If the human explicitly configured `--no-email-verification`, there is no email
150
- step; continue to pairing. This option is intended only for trusted local
161
+ step; continue to pairing and Chrome client approval. This option is intended only for trusted local
151
162
  clients and cannot be combined with a public tunnel.
152
163
 
153
164
  Each verification wait lasts up to 45 seconds. If the result says
@@ -165,6 +176,19 @@ is still unpaired, the human clicks **Connect** in the extension popup after
165
176
  email verification. If Chrome reports no paired browser, follow that step and
166
177
  retry the tool.
167
178
 
179
+ When the tool asks for Chrome approval, relay its friendly one-line steps with
180
+ the app name and key fingerprint. They click the extension's toolbar icon, review **App
181
+ connections**, compare the key, then click **Approve for 8 hours**. The same
182
+ controls are in Settings. Never click
183
+ Approve for them or attempt to change the extension's approval storage. Retry
184
+ the tool after they approve. YOLO, bypass-permission mode and
185
+ `--no-email-verification` do not disable this check.
186
+
187
+ Approval belongs to one app session and one Chrome profile. A restarted MCP
188
+ connection creates a new key; expired access requires approval again. The person
189
+ can revoke one key from the popup or Settings without revoking the others. Update both the
190
+ server and extension; old versions cannot silently bypass this gate.
191
+
168
192
  ## Step 6 — Check the pairing
169
193
 
170
194
  Pairing needs no paste. Every time the server boots it writes `pairing.json`
@@ -0,0 +1,25 @@
1
+ export declare const ACCESS_STOPPED_KEY = "accessStopped";
2
+ export declare const ACTIVITY_KEY = "recentActivity";
3
+ export declare const ACTIVITY_LIMIT = 100;
4
+ export type ActivityOutcome = 'running' | 'done' | 'blocked' | 'failed' | 'interrupted';
5
+ export interface ActivityEntry {
6
+ id: string;
7
+ at: number;
8
+ method: string;
9
+ host: string;
10
+ /** Browser-session tab identity, used only to pair opening and closing. */
11
+ tabId?: number;
12
+ app: string;
13
+ key: string;
14
+ outcome: ActivityOutcome;
15
+ }
16
+ /** Deliberately excludes paths, queries, fragments, credentials and page data. */
17
+ export declare function activityHost(url: string): string;
18
+ /** Reconstruct a fixed schema; never spread caller-supplied command data into storage. */
19
+ export declare function activityEntry(value: ActivityEntry): ActivityEntry;
20
+ export interface ActivityRow extends ActivityEntry {
21
+ opened?: ActivityEntry;
22
+ }
23
+ /** Pair completed actions on the same tab and app; never merge by website alone. */
24
+ export declare function activityRows(entries: ActivityEntry[]): ActivityRow[];
25
+ export declare const activityLabel: (method: string) => string;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.activityLabel = exports.ACTIVITY_LIMIT = exports.ACTIVITY_KEY = exports.ACCESS_STOPPED_KEY = void 0;
4
+ exports.activityHost = activityHost;
5
+ exports.activityEntry = activityEntry;
6
+ exports.activityRows = activityRows;
7
+ const protocol_1 = require("./protocol");
8
+ const client_auth_1 = require("./client-auth");
9
+ exports.ACCESS_STOPPED_KEY = 'accessStopped';
10
+ exports.ACTIVITY_KEY = 'recentActivity';
11
+ exports.ACTIVITY_LIMIT = 100;
12
+ /** Deliberately excludes paths, queries, fragments, credentials and page data. */
13
+ function activityHost(url) {
14
+ if (url === 'about:blank')
15
+ return url;
16
+ if (url === 'chrome://newtab/' || url === 'chrome://newtab' || url === 'chrome://new-tab-page/')
17
+ return 'chrome://newtab';
18
+ try {
19
+ const parsed = new URL(url);
20
+ return ['http:', 'https:'].includes(parsed.protocol) ? parsed.hostname.slice(0, 253) : '';
21
+ }
22
+ catch {
23
+ return '';
24
+ }
25
+ }
26
+ /** Reconstruct a fixed schema; never spread caller-supplied command data into storage. */
27
+ function activityEntry(value) {
28
+ return {
29
+ id: value.id.slice(0, 80),
30
+ at: value.at,
31
+ method: protocol_1.WIRE_METHODS.includes(value.method) ? value.method : 'unknown',
32
+ host: activityHost(value.host) || activityHost(`https://${value.host}/`),
33
+ ...(Number.isSafeInteger(value.tabId) && value.tabId >= 0 ? { tabId: value.tabId } : {}),
34
+ app: value.key ? (0, client_auth_1.clientName)(value.app) : 'Unverified app',
35
+ key: /^[a-f0-9]{64}$/.test(value.key) ? value.key : '',
36
+ outcome: ['running', 'done', 'blocked', 'failed', 'interrupted'].includes(value.outcome) ? value.outcome : 'failed',
37
+ };
38
+ }
39
+ /** Pair completed actions on the same tab and app; never merge by website alone. */
40
+ function activityRows(entries) {
41
+ const rows = [];
42
+ const opens = new Map();
43
+ for (const entry of entries) {
44
+ const key = entry.tabId !== undefined && entry.key ? `${entry.key}:${entry.tabId}` : '';
45
+ if (key && entry.method === 'tab_new' && entry.outcome === 'done')
46
+ opens.set(key, entry);
47
+ const opened = key && entry.method === 'tab_close' && entry.outcome === 'done' ? opens.get(key) : undefined;
48
+ if (opened) {
49
+ const index = rows.findIndex((row) => row.id === opened.id);
50
+ if (index !== -1)
51
+ rows.splice(index, 1);
52
+ rows.push({ ...entry, host: entry.host || opened.host, opened });
53
+ opens.delete(key);
54
+ }
55
+ else
56
+ rows.push(entry);
57
+ }
58
+ return rows;
59
+ }
60
+ const LABELS = {
61
+ tabs_list: 'List tabs', tab_select: 'Select tab', tab_new: 'Open tab', tab_close: 'Close tab',
62
+ navigate: 'Navigate', back: 'Go back', forward: 'Go forward', reload: 'Reload page',
63
+ click: 'Click', draw: 'Draw', type: 'Type text', press: 'Press key', hover: 'Hover',
64
+ scroll: 'Scroll', screenshot: 'Take screenshot', get_text: 'Read text', get_html: 'Read HTML',
65
+ eval: 'Run page script', snapshot: 'Read page structure', fill_form: 'Fill form',
66
+ get_cookies: 'Read cookies', storage: 'Access site storage', download_file: 'Download file',
67
+ upload_file: 'Upload file', print_pdf: 'Print PDF', select_option: 'Choose option',
68
+ observers: 'Read page observations', proxy_status: 'Check proxy', frames: 'List frames',
69
+ };
70
+ const activityLabel = (method) => LABELS[method] ?? method.replaceAll('_', ' ');
71
+ exports.activityLabel = activityLabel;
72
+ //# sourceMappingURL=activity.js.map
@@ -0,0 +1,20 @@
1
+ /** Only named HTTP(S) sites may be requested; never a catch-all or a file URL. */
2
+ export declare function siteOrigins(input: string): string[];
3
+ /** Match patterns have no port or path scope for host permissions. */
4
+ export declare function originForUrl(url: string): string | null;
5
+ export declare function isGlobalOrigin(origin: string): boolean;
6
+ export declare const OPTIONAL_FEATURES: {
7
+ readonly cookies: {
8
+ readonly label: "Cookies";
9
+ readonly explanation: "Read cookies on websites you have allowed. Cookie values still follow your server settings.";
10
+ };
11
+ readonly downloads: {
12
+ readonly label: "Downloads";
13
+ readonly explanation: "Save files from websites you have allowed.";
14
+ };
15
+ };
16
+ export type OptionalFeature = keyof typeof OPTIONAL_FEATURES;
17
+ export declare const FEATURE_REQUESTS_KEY = "browserPermissionRequests";
18
+ export declare const PERMISSIONS_MIGRATION_KEY = "optionalBrowserPermissionsV1";
19
+ export declare const PROXY_AUTH_PERMISSIONS: string[];
20
+ export declare const IP_LOOKUP_ORIGINS: string[];
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IP_LOOKUP_ORIGINS = exports.PROXY_AUTH_PERMISSIONS = exports.PERMISSIONS_MIGRATION_KEY = exports.FEATURE_REQUESTS_KEY = exports.OPTIONAL_FEATURES = void 0;
4
+ exports.siteOrigins = siteOrigins;
5
+ exports.originForUrl = originForUrl;
6
+ exports.isGlobalOrigin = isGlobalOrigin;
7
+ const policy_1 = require("./policy");
8
+ /** Only named HTTP(S) sites may be requested; never a catch-all or a file URL. */
9
+ function siteOrigins(input) {
10
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input) && !/^https?:\/\//i.test(input))
11
+ return [];
12
+ const pattern = (0, policy_1.normalizeDomainPattern)(input);
13
+ const host = pattern.startsWith('*.') ? pattern.slice(2) : pattern;
14
+ if (!host || host.length > 253 ||
15
+ !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/.test(host))
16
+ return [];
17
+ return [`http://${pattern}/*`, `https://${pattern}/*`];
18
+ }
19
+ /** Match patterns have no port or path scope for host permissions. */
20
+ function originForUrl(url) {
21
+ try {
22
+ const parsed = new URL(url);
23
+ return /^https?:$/.test(parsed.protocol) ? `${parsed.protocol}//${parsed.hostname}/*` : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function isGlobalOrigin(origin) {
30
+ return origin === '<all_urls>' || /^(?:\*|[a-z]+):\/\/\*\/.*$/i.test(origin);
31
+ }
32
+ exports.OPTIONAL_FEATURES = {
33
+ cookies: { label: 'Cookies', explanation: 'Read cookies on websites you have allowed. Cookie values still follow your server settings.' },
34
+ downloads: { label: 'Downloads', explanation: 'Save files from websites you have allowed.' },
35
+ };
36
+ exports.FEATURE_REQUESTS_KEY = 'browserPermissionRequests';
37
+ exports.PERMISSIONS_MIGRATION_KEY = 'optionalBrowserPermissionsV1';
38
+ exports.PROXY_AUTH_PERMISSIONS = ['webRequest', 'webRequestAuthProvider'];
39
+ exports.IP_LOOKUP_ORIGINS = ['https://ipinfo.io/*', 'https://api.ipify.org/*'];
40
+ //# sourceMappingURL=browser-permissions.js.map
@@ -0,0 +1,28 @@
1
+ /** Per-session authorization, enforced by the extension independently of the bridge token. */
2
+ import type { CommandFrame } from './protocol';
3
+ export declare const CLIENT_APPROVAL_CAP = "client-approval-v1";
4
+ export declare const CLIENT_GRANT_MS: number;
5
+ export declare const CLIENT_CLOCK_SKEW_MS: number;
6
+ export declare const CLIENT_REQUEST_MS: number;
7
+ export declare const CLIENT_STORAGE_KEY = "clientConnections";
8
+ export interface ClientProof {
9
+ publicKey: string;
10
+ name: string;
11
+ challenge: string;
12
+ issuedAt: number;
13
+ nonce: string;
14
+ signature: string;
15
+ }
16
+ export interface ClientConnection {
17
+ id: string;
18
+ publicKey: string;
19
+ name: string;
20
+ state: 'pending' | 'approved' | 'revoked';
21
+ requestedAt: number;
22
+ expiresAt: number;
23
+ }
24
+ export declare function clientName(value: string): string;
25
+ /** Correlation id is assigned by the hub; every executable field is signed. */
26
+ export declare function clientSigningPayload(cmd: Pick<CommandFrame, 'v' | 'method' | 'params' | 'tabId' | 'timeoutMs'>, proof: Omit<ClientProof, 'signature'>): string;
27
+ /** The only unsigned command allowed returns a fixed health response, without a tab lookup. */
28
+ export declare function isHealthProbe(cmd: Pick<CommandFrame, 'method' | 'params'>): boolean;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLIENT_STORAGE_KEY = exports.CLIENT_REQUEST_MS = exports.CLIENT_CLOCK_SKEW_MS = exports.CLIENT_GRANT_MS = exports.CLIENT_APPROVAL_CAP = void 0;
4
+ exports.clientName = clientName;
5
+ exports.clientSigningPayload = clientSigningPayload;
6
+ exports.isHealthProbe = isHealthProbe;
7
+ exports.CLIENT_APPROVAL_CAP = 'client-approval-v1';
8
+ exports.CLIENT_GRANT_MS = 8 * 60 * 60 * 1000;
9
+ exports.CLIENT_CLOCK_SKEW_MS = 2 * 60 * 1000;
10
+ exports.CLIENT_REQUEST_MS = 10 * 60 * 1000;
11
+ exports.CLIENT_STORAGE_KEY = 'clientConnections';
12
+ function clientName(value) {
13
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '').trim().slice(0, 80) || 'Unnamed app';
14
+ }
15
+ /** Normalize as JSON first: omitted properties must sign identically across the wire. */
16
+ function canonical(value) {
17
+ if (Array.isArray(value))
18
+ return `[${value.map(canonical).join(',')}]`;
19
+ if (value && typeof value === 'object') {
20
+ const obj = value;
21
+ return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`;
22
+ }
23
+ return JSON.stringify(value);
24
+ }
25
+ /** Correlation id is assigned by the hub; every executable field is signed. */
26
+ function clientSigningPayload(cmd, proof) {
27
+ return canonical(JSON.parse(JSON.stringify({
28
+ domain: exports.CLIENT_APPROVAL_CAP, v: cmd.v, method: cmd.method, params: cmd.params,
29
+ tabId: cmd.tabId ?? null, timeoutMs: cmd.timeoutMs,
30
+ publicKey: proof.publicKey, name: proof.name, challenge: proof.challenge,
31
+ issuedAt: proof.issuedAt, nonce: proof.nonce,
32
+ })));
33
+ }
34
+ /** The only unsigned command allowed returns a fixed health response, without a tab lookup. */
35
+ function isHealthProbe(cmd) {
36
+ return cmd.method === 'ping_probe' && Object.keys(cmd.params ?? {}).length === 0;
37
+ }
38
+ //# sourceMappingURL=client-auth.js.map
@@ -103,7 +103,7 @@ export declare function readObservers(globalName: string, opts: {
103
103
  * Translate the domain allowlist into Chrome match patterns for registering the
104
104
  * hook as a content script. A bare host becomes `*://host/*`; `*.host` becomes
105
105
  * `*://*.host/*` (which Chrome reads as the host AND its subdomains, matching
106
- * how the allowlist itself behaves); `*` becomes `<all_urls>`. Entries carrying
106
+ * how the allowlist itself behaves). Catch-all entries and entries carrying
107
107
  * no host are dropped rather than widened — the page the tool may not read is
108
108
  * also the page it must not instrument.
109
109
  */
@@ -106,7 +106,7 @@ function readObservers(globalName, opts) {
106
106
  * Translate the domain allowlist into Chrome match patterns for registering the
107
107
  * hook as a content script. A bare host becomes `*://host/*`; `*.host` becomes
108
108
  * `*://*.host/*` (which Chrome reads as the host AND its subdomains, matching
109
- * how the allowlist itself behaves); `*` becomes `<all_urls>`. Entries carrying
109
+ * how the allowlist itself behaves). Catch-all entries and entries carrying
110
110
  * no host are dropped rather than widened — the page the tool may not read is
111
111
  * also the page it must not instrument.
112
112
  */
@@ -118,8 +118,6 @@ function observerMatches(policy) {
118
118
  const p = (0, policy_1.normalizeDomainPattern)(raw);
119
119
  if (!p)
120
120
  continue;
121
- if (p === '*')
122
- return ['<all_urls>'];
123
121
  out.add(`*://${p}/*`);
124
122
  }
125
123
  return [...out];
@@ -22,8 +22,8 @@ export declare function hostOf(url: string): string;
22
22
  * a full URL ("https://example.com/app") or a "host:port/path" instead of a bare
23
23
  * host; those forms would never equal a hostname and so silently match nothing.
24
24
  * We strip the scheme, userinfo, port, and path/query/fragment, preserving a
25
- * leading "*." wildcard and the two catch-all forms. Returns '' for a pattern
26
- * that carries no host (which then matches nothing).
25
+ * leading "*." for a named site's subdomains. Catch-all patterns and patterns
26
+ * without a host return '' and never grant access.
27
27
  */
28
28
  export declare function normalizeDomainPattern(pattern: string): string;
29
29
  export declare function isDomainAllowed(url: string, policy: WirePolicy): boolean;
@@ -40,11 +40,9 @@ export type PolicyVerdict = {
40
40
  */
41
41
  export declare function evaluatePolicy(url: string, method: WireMethod, policy: WirePolicy): PolicyVerdict;
42
42
  /**
43
- * A plain-English, actionable message for a blocked domain. Tells the user what
44
- * happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
45
- * the exact one-line change to permit this one — so a non-technical user is never
46
- * left at a dead end. Never includes the token or any other secret.
43
+ * One friendly line with the person's next step. The agent must relay it and
44
+ * wait; only the person can approve the site in Chrome.
47
45
  */
48
- export declare function blockedDomainMessage(method: string, host: string, policy: WirePolicy): string;
46
+ export declare function blockedDomainMessage(host: string): string;
49
47
  /** A wire policy that allows nothing — the safe default when none was delivered. */
50
48
  export declare const DENY_ALL_WIRE_POLICY: WirePolicy;
@@ -103,28 +103,26 @@ function isAboutBlank(url) {
103
103
  * a full URL ("https://example.com/app") or a "host:port/path" instead of a bare
104
104
  * host; those forms would never equal a hostname and so silently match nothing.
105
105
  * We strip the scheme, userinfo, port, and path/query/fragment, preserving a
106
- * leading "*." wildcard and the two catch-all forms. Returns '' for a pattern
107
- * that carries no host (which then matches nothing).
106
+ * leading "*." for a named site's subdomains. Catch-all patterns and patterns
107
+ * without a host return '' and never grant access.
108
108
  */
109
109
  function normalizeDomainPattern(pattern) {
110
110
  let p = pattern.trim().toLowerCase();
111
- if (p === '*' || p === '*://*/*')
112
- return '*';
111
+ if (p === '*' || p === '*://*/*' || p === '<all_urls>')
112
+ return '';
113
113
  // Strip a leading scheme ("https://", "http://", any "scheme://").
114
114
  p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
115
115
  // Drop everything from the first path/query/fragment separator onward.
116
116
  p = p.replace(/[/?#].*$/, '');
117
117
  // Strip userinfo ("user:pass@") then a trailing port (":8080").
118
118
  p = p.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
119
- return p;
119
+ return p === '*' || p === '*.' ? '' : p;
120
120
  }
121
- /** Convert a single domain glob to a predicate. '*' matches everything;
121
+ /** Convert a named domain pattern to a predicate.
122
122
  * '*.example.com' matches example.com and any subdomain; otherwise exact host.
123
123
  * URL/port/path forms are normalized to their bare host first. */
124
124
  function globMatches(host, pattern) {
125
125
  const p = normalizeDomainPattern(pattern);
126
- if (p === '*')
127
- return true;
128
126
  if (p.startsWith('*.')) {
129
127
  const base = p.slice(2);
130
128
  return host === base || host.endsWith('.' + base);
@@ -179,29 +177,16 @@ function evaluatePolicy(url, method, policy) {
179
177
  return { ok: true };
180
178
  if (!isDomainAllowed(url, policy)) {
181
179
  const host = hostOf(url) || url;
182
- return { ok: false, reason: blockedDomainMessage(method, host, policy) };
180
+ return { ok: false, reason: blockedDomainMessage(host) };
183
181
  }
184
182
  return { ok: true };
185
183
  }
186
184
  /**
187
- * A plain-English, actionable message for a blocked domain. Tells the user what
188
- * happened, why it's blocked (safety, not a bug), which sites ARE allowed, and
189
- * the exact one-line change to permit this one — so a non-technical user is never
190
- * left at a dead end. Never includes the token or any other secret.
185
+ * One friendly line with the person's next step. The agent must relay it and
186
+ * wait; only the person can approve the site in Chrome.
191
187
  */
192
- function blockedDomainMessage(method, host, policy) {
193
- const allowed = policy.allowDomains.filter((d) => d !== '*');
194
- const allowedLine = allowed.length
195
- ? `Currently allowed: ${allowed.join(', ')}.`
196
- : `Right now no sites are allowed.`;
197
- return (`Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. ` +
198
- `This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) — not an error. ` +
199
- `${allowedLine} ` +
200
- `To allow ${host} right now, ask the user to click the MCP Browser Extension's toolbar icon in Chrome ` +
201
- `and click Allow next to ${host}: it applies at once, no restart. ` +
202
- `You cannot approve a site yourself. ` +
203
- `Or add --allow-domain "${host}" (or "*.${host}" to include subdomains) to the MCP Browser Extension server settings (its entry in your MCP config) and restart. ` +
204
- `To allow every site (less safe), use --unsafe-all-domains.`);
188
+ function blockedDomainMessage(host) {
189
+ return `Please allow access to ${host}: Chrome → MCP Browser Extension icon → Allow next to ${host}, then try again.`;
205
190
  }
206
191
  /** A wire policy that allows nothing — the safe default when none was delivered. */
207
192
  exports.DENY_ALL_WIRE_POLICY = {
@@ -1,3 +1,4 @@
1
+ import type { ClientProof } from './client-auth';
1
2
  /**
2
3
  * shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
3
4
  *
@@ -132,6 +133,8 @@ export interface BaseFrame {
132
133
  v: ProtocolVersion;
133
134
  }
134
135
  export interface HelloFrame extends BaseFrame {
136
+ /** Random challenge chosen by the extension for this WebSocket, never by the hub. */
137
+ approvalChallenge?: string;
135
138
  type: 'hello';
136
139
  token: string;
137
140
  ext: {
@@ -200,6 +203,7 @@ export interface UnauthFrame extends BaseFrame {
200
203
  reason: 'bad_token' | 'bad_version' | 'timeout';
201
204
  }
202
205
  export interface CommandFrame extends BaseFrame {
206
+ authorization?: ClientProof;
203
207
  type: 'command';
204
208
  id: string;
205
209
  method: WireMethod;
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_PROXY = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
4
+ exports.fillFormTimeoutMs = fillFormTimeoutMs;
2
5
  /**
3
6
  * shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
4
7
  *
@@ -14,9 +17,6 @@
14
17
  * beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
15
18
  * in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
16
19
  */
17
- Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.WIRE_METHODS = exports.FILL_FORM_MAX_TIMEOUT_MS = exports.FILL_FORM_PER_FIELD_MS = exports.FILL_FORM_MIN_TIMEOUT_MS = exports.WIRE_CAP_PROXY = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
19
- exports.fillFormTimeoutMs = fillFormTimeoutMs;
20
20
  /** Bumped on any breaking change to the frames below. */
21
21
  exports.PROTOCOL_VERSION = 1;
22
22
  /**