mcp-browser-extension 0.9.35 → 0.9.38

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 (58) hide show
  1. package/README.md +33 -15
  2. package/dist/shared/client-auth.d.ts +2 -0
  3. package/dist/shared/phone-pairing.d.ts +35 -0
  4. package/dist/shared/phone-pairing.js +10 -0
  5. package/dist/shared/policy.js +7 -0
  6. package/dist/shared/protocol.d.ts +13 -2
  7. package/dist/shared/protocol.js +13 -1
  8. package/dist/shared/site-risk.d.ts +31 -0
  9. package/dist/shared/site-risk.js +190 -0
  10. package/dist/src/bridge/client-identity.d.ts +11 -5
  11. package/dist/src/bridge/client-identity.js +7 -4
  12. package/dist/src/bridge/connection.js +1 -0
  13. package/dist/src/bridge/server.d.ts +1 -0
  14. package/dist/src/bridge/server.js +38 -19
  15. package/dist/src/bridge/workspace.d.ts +1 -0
  16. package/dist/src/bridge/workspace.js +11 -3
  17. package/dist/src/cli.js +61 -18
  18. package/dist/src/config.d.ts +2 -0
  19. package/dist/src/config.js +26 -1
  20. package/dist/src/executor/extension-executor.d.ts +1 -0
  21. package/dist/src/executor/extension-executor.js +10 -1
  22. package/dist/src/executor/types.d.ts +3 -1
  23. package/dist/src/mcp/helpers.js +18 -6
  24. package/dist/src/mcp/person-wait.js +2 -1
  25. package/dist/src/mcp/server.d.ts +2 -1
  26. package/dist/src/mcp/server.js +4 -4
  27. package/dist/src/mcp/tools.d.ts +2 -2
  28. package/dist/src/mcp/tools.js +46 -9
  29. package/dist/src/native-host.js +11 -1
  30. package/dist/src/remote/claude-chat.d.ts +21 -0
  31. package/dist/src/remote/claude-chat.js +209 -0
  32. package/dist/src/remote/codex-rpc.d.ts +19 -0
  33. package/dist/src/remote/codex-rpc.js +94 -0
  34. package/dist/src/remote/http.d.ts +5 -0
  35. package/dist/src/remote/http.js +99 -7
  36. package/dist/src/remote/key.d.ts +4 -0
  37. package/dist/src/remote/key.js +6 -2
  38. package/dist/src/remote/phone-chat.d.ts +57 -0
  39. package/dist/src/remote/phone-chat.js +366 -0
  40. package/dist/src/remote/phone-control.d.ts +6 -0
  41. package/dist/src/remote/phone-control.js +132 -0
  42. package/dist/src/remote/phone-pairing.d.ts +41 -0
  43. package/dist/src/remote/phone-pairing.js +180 -0
  44. package/dist/src/remote/relay.d.ts +37 -0
  45. package/dist/src/remote/relay.js +207 -0
  46. package/docs/CLIENT-APPROVAL.md +44 -9
  47. package/docs/PHONE-PAIRING.md +97 -0
  48. package/extension-dist/THIRD-PARTY-NOTICES.txt +35 -0
  49. package/extension-dist/activity-dashboard.css +4 -9
  50. package/extension-dist/background.js +725 -127
  51. package/extension-dist/brand.css +63 -0
  52. package/extension-dist/manifest.json +1 -1
  53. package/extension-dist/options.html +25 -22
  54. package/extension-dist/options.js +179 -42
  55. package/extension-dist/popup.html +33 -41
  56. package/extension-dist/popup.js +2529 -126
  57. package/extension-dist/site-protection.css +11 -0
  58. package/package.json +13 -2
@@ -38,10 +38,16 @@
38
38
 
39
39
  // shared/protocol.ts
40
40
  var PROTOCOL_VERSION = 1;
41
+ var MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
41
42
  var CLOSE_SUPERSEDED = 4e3;
42
43
  var WIRE_CAP_TAB_URL = "tab-url";
44
+ var WIRE_CAP_SITE_CHECKS = "site-checks-v1";
43
45
  var WIRE_CAP_FILL_FORM = "fill-form";
44
46
  var WIRE_CAP_PROXY = "proxy";
47
+ function drawTimeoutMs(points, strokes) {
48
+ const events = Math.max(0, points) + 2 * Math.max(0, strokes);
49
+ return Math.min(24e4, Math.max(3e4, 1e4 + events * 60));
50
+ }
45
51
  var WIRE_METHODS = [
46
52
  "tabs_list",
47
53
  "tab_select",
@@ -73,7 +79,8 @@
73
79
  "print_pdf",
74
80
  "fill_form",
75
81
  "proxy_status",
76
- "ping_probe"
82
+ "ping_probe",
83
+ "site_check"
77
84
  ];
78
85
 
79
86
  // extension/src/sw/cmd-error.ts
@@ -85,6 +92,11 @@
85
92
  code;
86
93
  };
87
94
 
95
+ // shared/phone-pairing.ts
96
+ var PHONE_INVITATION_MS = 2 * 6e4;
97
+ var PHONE_ACCESS_MS = 30 * 24 * 60 * 6e4;
98
+ var PHONE_ACTIONS = ["state", "create", "cancel", "approve", "deny", "remove"];
99
+
88
100
  // extension/src/sw/client-approvals.ts
89
101
  var MAX_CLIENTS = 64;
90
102
  var MAX_NONCES = 4096;
@@ -223,6 +235,33 @@
223
235
  }
224
236
  });
225
237
  }
238
+ /** A human approved a scanned phone in our own popup. Validate its local helper identity. */
239
+ approvePairedPhone(phone) {
240
+ return this.serial(async () => {
241
+ await this.load();
242
+ if (phone.state !== "pending" || phone.expiresAt <= this.now() || phone.name !== clientName(phone.name)) {
243
+ return denied("This phone request expired. Generate a new QR code.");
244
+ }
245
+ const der = decode(phone.publicKey);
246
+ await crypto.subtle.importKey("spki", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
247
+ const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", der)), (b) => b.toString(16).padStart(2, "0")).join("");
248
+ if (id !== phone.clientId) return denied("Phone identity is invalid.");
249
+ const clients = this.clients.filter((c) => c.id !== id && c.expiresAt > this.now());
250
+ if (clients.length >= MAX_CLIENTS) return denied("Remove an unused app connection before pairing a phone.");
251
+ await this.save([...clients, {
252
+ id,
253
+ publicKey: phone.publicKey,
254
+ name: phone.name,
255
+ kind: "phone",
256
+ phoneId: phone.id,
257
+ state: "approved",
258
+ requestedAt: this.now(),
259
+ expiresAt: this.now() + PHONE_ACCESS_MS
260
+ }]);
261
+ this.storageFault = false;
262
+ this.revoking.delete(id);
263
+ });
264
+ }
226
265
  /** Called exclusively by messages from the extension's own approval UI. */
227
266
  decide(id, allow) {
228
267
  if (!allow) this.revoking.add(id);
@@ -253,6 +292,99 @@
253
292
  return sender.id === chrome.runtime.id && [chrome.runtime.getURL("options.html"), chrome.runtime.getURL("popup.html")].includes(sender.url ?? "") && (sender.frameId === void 0 || sender.frameId === 0);
254
293
  }
255
294
 
295
+ // extension/src/connect.ts
296
+ var NATIVE_HOST = "com.mehmoodqureshi.mcp_browser_extension";
297
+ async function fetchPairing() {
298
+ if (typeof chrome.runtime.sendNativeMessage !== "function") {
299
+ return { ok: false, error: "restart_needed", message: "Finishing setup. Click Connect again if this stays." };
300
+ }
301
+ try {
302
+ const reply = await chrome.runtime.sendNativeMessage(NATIVE_HOST, { type: "pairing" });
303
+ return reply ?? { ok: false, message: "The helper gave no answer." };
304
+ } catch (err) {
305
+ const why = err instanceof Error ? err.message : String(err);
306
+ if (/not found|not registered|Specified native messaging host/i.test(why)) {
307
+ return {
308
+ ok: false,
309
+ error: "no_helper",
310
+ message: "Add the MCP Browser Extension server to your AI client (Claude Code, Cursor\u2026) and start it once; it sets this up. Then click Connect again."
311
+ };
312
+ }
313
+ return { ok: false, message: why };
314
+ }
315
+ }
316
+ async function applyPairing(reply) {
317
+ await chrome.storage.local.set({
318
+ wsPort: reply.port,
319
+ token: reply.token,
320
+ // The server's own --profile, as a suggestion only: another browser may
321
+ // already use that name, and taking it over would disconnect that one.
322
+ suggestedProfile: reply.profile ?? "",
323
+ pairingSource: "native"
324
+ });
325
+ }
326
+ async function requestPairingFromClick() {
327
+ let granted = false;
328
+ try {
329
+ granted = await chrome.permissions.request({ permissions: ["nativeMessaging"] });
330
+ } catch (err) {
331
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
332
+ }
333
+ if (!granted) return { ok: false, message: "Chrome needs that permission to fetch the pairing for you. You can also pair by hand in Settings." };
334
+ return fetchPairing();
335
+ }
336
+
337
+ // extension/src/sw/phone-pairing.ts
338
+ async function phonePairingAction(action, id, deps) {
339
+ if (!PHONE_ACTIONS.includes(action)) return { ok: false, message: "Invalid phone pairing action." };
340
+ if (["create", "approve"].includes(action) && (deps.stopped() || !deps.connected())) {
341
+ return { ok: false, message: "Connect this browser and resume access before pairing a phone." };
342
+ }
343
+ const { pairedProfile } = await chrome.storage.local.get("pairedProfile");
344
+ if (typeof pairedProfile !== "string" || !pairedProfile) return { ok: false, message: "Connect this browser first, then pair your phone." };
345
+ if (typeof chrome.runtime.sendNativeMessage !== "function") return { ok: false, message: "Click Connect in the extension, then reopen Pair phone." };
346
+ const send = async (action2) => {
347
+ const reply = await chrome.runtime.sendNativeMessage(NATIVE_HOST, { type: "phone_pairing", profile: pairedProfile, action: action2, id });
348
+ if (reply?.ok) {
349
+ const stored = (await chrome.storage.local.get(CLIENT_STORAGE_KEY))[CLIENT_STORAGE_KEY];
350
+ const clients = Array.isArray(stored) ? stored : [];
351
+ for (const phone of reply.state.devices) {
352
+ if (phone.state === "paired") phone.browserApproved = clients.some((c) => c.id === phone.clientId && c.state === "approved" && c.expiresAt > Date.now());
353
+ }
354
+ }
355
+ return reply;
356
+ };
357
+ try {
358
+ if (action === "approve" || action === "remove") {
359
+ const current = await send("state");
360
+ if (!current?.ok) return current ?? { ok: false, message: "Phone pairing did not answer." };
361
+ const phone = current.state.devices.find((d) => d.id === id);
362
+ if (!phone) return { ok: false, message: "That phone request has expired or was removed." };
363
+ if (action === "approve") {
364
+ if (deps.stopped() || !deps.connected()) return { ok: false, message: "Resume browser access before approving this phone." };
365
+ await deps.approvals.approvePairedPhone(phone);
366
+ if (deps.stopped() || !deps.connected()) {
367
+ await deps.approvals.decide(phone.clientId, false);
368
+ return { ok: false, message: "Browser access stopped. Pair your phone again after resuming." };
369
+ }
370
+ try {
371
+ const result = await send("approve");
372
+ if (!result?.ok) await deps.approvals.decide(phone.clientId, false);
373
+ return result ?? { ok: false, message: "Could not approve this phone." };
374
+ } catch (err) {
375
+ await deps.approvals.decide(phone.clientId, false).catch(() => void 0);
376
+ throw err;
377
+ }
378
+ }
379
+ await deps.approvals.decide(phone.clientId, false).catch(() => void 0);
380
+ return await send("remove");
381
+ }
382
+ return await send(action);
383
+ } catch {
384
+ return { ok: false, message: "Could not reach phone pairing. Update the local server and try again." };
385
+ }
386
+ }
387
+
256
388
  // shared/activity.ts
257
389
  var ACCESS_STOPPED_KEY = "accessStopped";
258
390
  var ACTIVITY_KEY = "recentActivity";
@@ -375,12 +507,12 @@
375
507
  token() {
376
508
  return this.generation;
377
509
  }
378
- record(entry, generation = this.generation) {
510
+ record(entry, generation2 = this.generation) {
379
511
  if (this.pending >= 256) return Promise.reject(new Error("Activity history is busy."));
380
512
  this.pending++;
381
513
  const safe = activityEntry(entry);
382
514
  return this.serial(async () => {
383
- if (generation !== this.generation) return;
515
+ if (generation2 !== this.generation) return;
384
516
  await this.load();
385
517
  const next = this.entries.filter((e) => e.id !== safe.id);
386
518
  next.push(safe);
@@ -453,7 +585,7 @@
453
585
  // This build gates fail-closed and reports tab URLs on results, so the
454
586
  // server may skip its pre-flight tabs_list. An older build omits this and
455
587
  // the server keeps fetching the URL itself.
456
- caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM, WIRE_CAP_PROXY, CLIENT_APPROVAL_CAP]
588
+ caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM, WIRE_CAP_PROXY, CLIENT_APPROVAL_CAP, WIRE_CAP_SITE_CHECKS]
457
589
  };
458
590
  ws2.send(JSON.stringify(hello));
459
591
  };
@@ -467,6 +599,7 @@
467
599
  }
468
600
  switch (frame.type) {
469
601
  case "welcome":
602
+ this.deps.onSiteChecks?.(frame.siteChecks === true);
470
603
  this.deps.onPolicy(frame.policy);
471
604
  if (typeof frame.profile === "string") this.deps.onProfile?.(frame.profile);
472
605
  this.deps.onGranted?.(Array.isArray(frame.granted) ? frame.granted : null);
@@ -485,7 +618,17 @@
485
618
  if (this.state === "connected" && frame.v === PROTOCOL_VERSION) {
486
619
  this.deps.onCommand(frame, (result) => {
487
620
  if (this.ws === ws2 && this.state === "connected" && ws2.readyState === WebSocket.OPEN) {
488
- ws2.send(JSON.stringify(result));
621
+ const payload = JSON.stringify(result);
622
+ if (new TextEncoder().encode(payload).byteLength > MAX_BRIDGE_FRAME_BYTES) {
623
+ const error = {
624
+ type: "error",
625
+ v: PROTOCOL_VERSION,
626
+ id: frame.id,
627
+ ok: false,
628
+ error: { code: "RESULT_TOO_LARGE", message: "[RESULT_TOO_LARGE] The browser result is too large to return. Request a smaller page section or a lower-resolution image." }
629
+ };
630
+ ws2.send(JSON.stringify(error));
631
+ } else ws2.send(payload);
489
632
  }
490
633
  });
491
634
  }
@@ -610,6 +753,9 @@
610
753
  function isDomainAllowed(url, policy) {
611
754
  const host = hostOf(url);
612
755
  if (!host) return false;
756
+ if (policy.automaticSites && /^https?:\/\//i.test(url)) {
757
+ return !(policy.blockedDomains ?? []).some((pat) => globMatches(host, pat));
758
+ }
613
759
  return policy.allowDomains.some((pat) => globMatches(host, pat));
614
760
  }
615
761
  function evaluatePolicy(url, method, policy) {
@@ -641,6 +787,10 @@
641
787
  if (isAboutBlank(url) && NAVIGATION.has(method)) return { ok: true };
642
788
  if (!isDomainAllowed(url, policy)) {
643
789
  const host = hostOf(url) || url;
790
+ if (policy.automaticSites && /^https?:\/\//i.test(url)) return {
791
+ ok: false,
792
+ reason: `Access to ${host} was removed. Restore it under Blocked websites in extension Settings.`
793
+ };
644
794
  return { ok: false, reason: blockedDomainMessage(host) };
645
795
  }
646
796
  return { ok: true };
@@ -1533,11 +1683,354 @@
1533
1683
  };
1534
1684
  }
1535
1685
 
1686
+ // shared/site-risk.ts
1687
+ var SITE_CHECKS_KEY = "localSiteChecksV1";
1688
+ var SITE_CHECKS_PENDING_KEY = "localSiteWarnings";
1689
+ var SITE_CHECKS_APPROVALS_KEY = "localSiteWarningApprovals";
1690
+ var SITE_CHECKS_BLOCKED_KEY = "localBlockedSites";
1691
+ var AUTOMATIC_SITE_ORIGINS = ["http://*/*", "https://*/*"];
1692
+ var BRANDS = {
1693
+ google: ["google.com", "google.co.uk", "google.com.pk", "google.de", "google.fr", "google.co.in", "google.com.au", "google.ca", "google.co.jp", "youtube.com"],
1694
+ microsoft: ["microsoft.com", "microsoftonline.com", "live.com", "office.com", "outlook.com"],
1695
+ apple: ["apple.com", "icloud.com"],
1696
+ paypal: ["paypal.com"],
1697
+ amazon: ["amazon.com", "amazon.co.uk", "amazon.de", "amazon.fr", "amazon.in", "amazon.ca", "amazon.com.au", "amazon.co.jp", "amazon.es", "amazon.it"],
1698
+ github: ["github.com", "github.dev"],
1699
+ facebook: ["facebook.com", "meta.com"],
1700
+ instagram: ["instagram.com", "meta.com"],
1701
+ linkedin: ["linkedin.com"],
1702
+ dropbox: ["dropbox.com"],
1703
+ coinbase: ["coinbase.com"]
1704
+ };
1705
+ var AUTH_WORD = /(?:^|[.\-])(login|signin|sign-in|verify|verification|secure|account|password|recover|support)(?:[.\-]|$)/;
1706
+ function hostIn(host, domains) {
1707
+ return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
1708
+ }
1709
+ function unicodeLabel(label) {
1710
+ if (!label.startsWith("xn--") || label.length > 63) return label;
1711
+ try {
1712
+ const input = label.slice(4);
1713
+ const split = input.lastIndexOf("-");
1714
+ const points = split < 0 ? [] : Array.from(input.slice(0, split), (c) => c.codePointAt(0));
1715
+ let pos = split < 0 ? 0 : split + 1;
1716
+ let n = 128, i = 0, bias = 72;
1717
+ while (pos < input.length) {
1718
+ const old = i;
1719
+ let weight = 1;
1720
+ for (let k2 = 36; ; k2 += 36) {
1721
+ const code = input.charCodeAt(pos++);
1722
+ const digit = code >= 97 && code <= 122 ? code - 97 : code >= 48 && code <= 57 ? code - 22 : 36;
1723
+ if (digit >= 36 || !Number.isSafeInteger(i + digit * weight)) return label;
1724
+ i += digit * weight;
1725
+ const t = k2 <= bias ? 1 : k2 >= bias + 26 ? 26 : k2 - bias;
1726
+ if (digit < t) break;
1727
+ weight *= 36 - t;
1728
+ }
1729
+ const size = points.length + 1;
1730
+ let delta = Math.floor((i - old) / (old === 0 ? 700 : 2));
1731
+ delta += Math.floor(delta / size);
1732
+ let k = 0;
1733
+ while (delta > 455) {
1734
+ delta = Math.floor(delta / 35);
1735
+ k += 36;
1736
+ }
1737
+ bias = k + Math.floor(36 * delta / (delta + 38));
1738
+ n += Math.floor(i / size);
1739
+ i %= size;
1740
+ if (n > 1114111 || n >= 55296 && n <= 57343) return label;
1741
+ points.splice(i++, 0, n);
1742
+ }
1743
+ return String.fromCodePoint(...points);
1744
+ } catch {
1745
+ return label;
1746
+ }
1747
+ }
1748
+ function skeleton(label) {
1749
+ const lookalikes = {
1750
+ "\u0430": "a",
1751
+ "\u0435": "e",
1752
+ "\u043E": "o",
1753
+ "\u0440": "p",
1754
+ "\u0441": "c",
1755
+ "\u0443": "y",
1756
+ "\u0445": "x",
1757
+ "\u0456": "i",
1758
+ "\u0458": "j",
1759
+ "\u04CF": "l",
1760
+ "\u0455": "s",
1761
+ "\u0501": "d",
1762
+ "\u051B": "q",
1763
+ "\u03B1": "a",
1764
+ "\u03BF": "o",
1765
+ "\u03C1": "p",
1766
+ "\u03BD": "v",
1767
+ "\u03B9": "i",
1768
+ "0": "o",
1769
+ "1": "l"
1770
+ };
1771
+ return Array.from(unicodeLabel(label).toLowerCase(), (c) => lookalikes[c] ?? c).join("");
1772
+ }
1773
+ function isLocalDevelopmentHost(host) {
1774
+ return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host);
1775
+ }
1776
+ function siteUrlWarnings(input) {
1777
+ let url;
1778
+ try {
1779
+ url = new URL(input);
1780
+ } catch {
1781
+ return [];
1782
+ }
1783
+ if (!/^https?:$/.test(url.protocol)) return [];
1784
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
1785
+ const warnings = [];
1786
+ if (url.username && /[.]/.test(decodeURIComponentSafe(url.username))) {
1787
+ warnings.push({ id: "hidden-destination", reason: `The address puts a website name before @. The actual destination is ${host}.` });
1788
+ }
1789
+ const labels = host.split(".");
1790
+ for (const [brand, domains] of Object.entries(BRANDS)) {
1791
+ if (hostIn(host, domains)) continue;
1792
+ if (domains.some((domain) => host.includes(`${domain}.`))) {
1793
+ warnings.push({ id: `misleading-domain:${brand}`, reason: `The address includes ${brand}'s domain but actually belongs to ${host}.` });
1794
+ continue;
1795
+ }
1796
+ if (labels.some((label) => label !== brand && skeleton(label) === brand)) {
1797
+ warnings.push({ id: `lookalike:${brand}`, reason: `The address uses lookalike characters to resemble ${brand}.` });
1798
+ continue;
1799
+ }
1800
+ if (labels.some((label) => label.split("-").includes(brand)) && AUTH_WORD.test(host)) {
1801
+ warnings.push({ id: `brand-login:${brand}`, reason: `The address combines ${brand}'s name with login or account wording outside its recognised domains.` });
1802
+ }
1803
+ }
1804
+ return warnings;
1805
+ }
1806
+ function decodeURIComponentSafe(value) {
1807
+ try {
1808
+ return decodeURIComponent(value);
1809
+ } catch {
1810
+ return value;
1811
+ }
1812
+ }
1813
+ function loginWarnings(signals) {
1814
+ const warnings = siteUrlWarnings(signals.url);
1815
+ if (!signals.passwords) return warnings;
1816
+ let page;
1817
+ try {
1818
+ page = new URL(signals.url);
1819
+ } catch {
1820
+ return warnings;
1821
+ }
1822
+ if (!/^https?:$/.test(page.protocol)) return warnings;
1823
+ const local = isLocalDevelopmentHost(page.hostname);
1824
+ if (page.protocol === "http:" && !local) warnings.push({
1825
+ id: "unencrypted-login",
1826
+ reason: "This page asks for a password over an unencrypted HTTP connection."
1827
+ });
1828
+ for (const form of signals.forms.slice(0, 32)) {
1829
+ let action;
1830
+ try {
1831
+ action = new URL(form.action || signals.url, signals.url);
1832
+ } catch {
1833
+ continue;
1834
+ }
1835
+ if (action.protocol === "http:" && !(local && isLocalDevelopmentHost(action.hostname))) warnings.push({
1836
+ id: `unencrypted-password-destination:${action.origin}`,
1837
+ reason: `A password form sends to an unencrypted address at ${action.hostname}.`
1838
+ });
1839
+ if (form.method === "get") warnings.push({
1840
+ id: "password-in-address",
1841
+ reason: "A password form is configured to put its fields in the website address."
1842
+ });
1843
+ }
1844
+ const title = signals.title.slice(0, 300).toLowerCase();
1845
+ if (/\b(sign[ -]?in|log[ -]?in|verify|account|password)\b/.test(title)) {
1846
+ for (const [brand, domains] of Object.entries(BRANDS)) {
1847
+ if (new RegExp(`\\b${brand}\\b`).test(title) && !hostIn(page.hostname, domains)) {
1848
+ warnings.push({ id: `login-brand:${brand}`, reason: `This password page presents itself as ${brand}, but its address is ${page.hostname}.` });
1849
+ }
1850
+ }
1851
+ }
1852
+ return uniqueWarnings(warnings);
1853
+ }
1854
+ function uniqueWarnings(warnings) {
1855
+ return [...new Map(warnings.map((warning) => [warning.id, warning])).values()];
1856
+ }
1857
+ function collectLoginSignals() {
1858
+ const inputs = Array.from(document.querySelectorAll('input[type="password"]')).filter((input) => !input.disabled && input.getClientRects().length > 0).slice(0, 32);
1859
+ const forms = [...new Set(inputs.map((input) => input.form).filter((form) => !!form))];
1860
+ return {
1861
+ url: location.href,
1862
+ title: document.title.slice(0, 300),
1863
+ passwords: inputs.length,
1864
+ forms: forms.map((form) => ({ action: form.action, method: form.getAttribute("method")?.toLowerCase() ?? "" }))
1865
+ };
1866
+ }
1867
+
1868
+ // extension/src/sw/site-protection.ts
1869
+ var enabled = false;
1870
+ var configured = false;
1871
+ var supported = true;
1872
+ var requesting = false;
1873
+ var blocked = [];
1874
+ var generation = 0;
1875
+ var queue = Promise.resolve();
1876
+ var changed = () => {
1877
+ };
1878
+ function onSiteWarnings(callback) {
1879
+ changed = callback;
1880
+ }
1881
+ function setSiteChecksSupported(value) {
1882
+ supported = value;
1883
+ }
1884
+ function siteChecksEnabled() {
1885
+ return supported && enabled;
1886
+ }
1887
+ function siteChecksConfigured() {
1888
+ return supported && configured;
1889
+ }
1890
+ function requireSiteChecksReady() {
1891
+ if (siteChecksConfigured() && !enabled) throw new CmdError("POLICY_DENIED", "Automatic website access needs Chrome permission. Open MCP Browser Extension and click Allow website access.");
1892
+ }
1893
+ async function keepAutomaticPermissions() {
1894
+ return requesting || (await chrome.storage.local.get(SITE_CHECKS_KEY))[SITE_CHECKS_KEY] === true;
1895
+ }
1896
+ async function refreshSiteChecks() {
1897
+ const mine = ++generation;
1898
+ const saved = await chrome.storage.local.get([SITE_CHECKS_KEY, SITE_CHECKS_BLOCKED_KEY, SITE_CHECKS_PENDING_KEY]);
1899
+ const wantsChecks = saved[SITE_CHECKS_KEY] !== false;
1900
+ const permitted = wantsChecks && await chrome.permissions.contains({ origins: AUTOMATIC_SITE_ORIGINS });
1901
+ if (mine !== generation) return;
1902
+ configured = wantsChecks;
1903
+ enabled = permitted;
1904
+ blocked = Array.isArray(saved[SITE_CHECKS_BLOCKED_KEY]) ? saved[SITE_CHECKS_BLOCKED_KEY].filter((s) => typeof s === "string") : [];
1905
+ changed(siteChecksEnabled() && Array.isArray(saved[SITE_CHECKS_PENDING_KEY]) ? saved[SITE_CHECKS_PENDING_KEY].length : 0, false);
1906
+ }
1907
+ async function changeSiteChecks(allow) {
1908
+ if (requesting) throw new Error("A website permission request is already open.");
1909
+ requesting = allow;
1910
+ try {
1911
+ const permission = allow ? chrome.permissions.request({ origins: AUTOMATIC_SITE_ORIGINS }) : Promise.resolve(true);
1912
+ if (!await permission) throw new Error("Automatic website access was not enabled. Accept Chrome\u2019s permission request to turn it on.");
1913
+ await chrome.storage.local.set({ [SITE_CHECKS_KEY]: allow });
1914
+ if (!allow) {
1915
+ enabled = false;
1916
+ await chrome.permissions.remove({ origins: AUTOMATIC_SITE_ORIGINS });
1917
+ }
1918
+ await refreshSiteChecks();
1919
+ } finally {
1920
+ requesting = false;
1921
+ }
1922
+ }
1923
+ function localSitePolicy(policy) {
1924
+ const active = siteChecksEnabled();
1925
+ return policy ? { ...policy, automaticSites: active, blockedDomains: active ? blocked : [] } : null;
1926
+ }
1927
+ function serialized(operation) {
1928
+ const next = queue.then(operation);
1929
+ queue = next.catch(() => {
1930
+ });
1931
+ return next;
1932
+ }
1933
+ async function warn(url, warnings) {
1934
+ return serialized(async () => {
1935
+ const saved = await chrome.storage.local.get([SITE_CHECKS_PENDING_KEY, SITE_CHECKS_APPROVALS_KEY]);
1936
+ const approved = saved[SITE_CHECKS_APPROVALS_KEY]?.[url.origin];
1937
+ warnings = warnings.filter((warning) => !Array.isArray(approved) || !approved.includes(warning.id));
1938
+ if (!warnings.length) return [];
1939
+ const prior = Array.isArray(saved[SITE_CHECKS_PENDING_KEY]) ? saved[SITE_CHECKS_PENDING_KEY] : [];
1940
+ const old = prior.find((entry) => entry.origin === url.origin);
1941
+ const fresh = !old || warnings.some((warning) => !old.warnings.some((w) => w.id === warning.id));
1942
+ const request = { origin: url.origin, host: url.hostname, warnings, at: Date.now() };
1943
+ const next = [request, ...prior.filter((entry) => entry.origin !== url.origin)].slice(0, 100);
1944
+ await chrome.storage.local.set({ [SITE_CHECKS_PENDING_KEY]: next });
1945
+ changed(next.length, fresh);
1946
+ return warnings;
1947
+ });
1948
+ }
1949
+ async function requireWarningApproval(url, warnings) {
1950
+ if (!warnings.length) return;
1951
+ const missing = await warn(url, warnings);
1952
+ if (!missing.length) return;
1953
+ throw new CmdError("POLICY_DENIED", `Suspicious website ${url.hostname}: ${missing.map((w) => w.reason).join(" ")} Review the warning in the MCP Browser Extension popup or Settings, then click Allow if you want to continue.`);
1954
+ }
1955
+ async function checkLocalSite(input, tabId, frameId = 0) {
1956
+ requireSiteChecksReady();
1957
+ if (!siteChecksEnabled() || input === "about:blank") return;
1958
+ let url;
1959
+ try {
1960
+ url = new URL(input);
1961
+ } catch {
1962
+ throw new CmdError("POLICY_DENIED", "This website address cannot be checked.");
1963
+ }
1964
+ if (!/^https?:$/.test(url.protocol)) throw new CmdError("POLICY_DENIED", "Automatic website access only covers HTTP and HTTPS pages.");
1965
+ if (blocked.some((host) => url.hostname === host || host.startsWith("*.") && (url.hostname === host.slice(2) || url.hostname.endsWith(`.${host.slice(2)}`)))) {
1966
+ throw new CmdError("POLICY_DENIED", `Access to ${url.hostname} was removed. Restore it under Blocked websites in extension Settings.`);
1967
+ }
1968
+ await requireWarningApproval(url, siteUrlWarnings(input));
1969
+ if (tabId !== void 0) {
1970
+ let warnings;
1971
+ try {
1972
+ const results = await chrome.scripting.executeScript({ target: { tabId, frameIds: [frameId] }, func: collectLoginSignals });
1973
+ const signals = results[0]?.result;
1974
+ if (!signals || signals.url !== input) throw new Error("The page changed during the check.");
1975
+ warnings = loginWarnings(signals);
1976
+ } catch {
1977
+ throw new CmdError("CDP_ERROR", "Could not finish the local website check. The page may have changed; retry the request.");
1978
+ }
1979
+ await requireWarningApproval(url, warnings);
1980
+ }
1981
+ }
1982
+ function allowSiteWarning(origin, expected) {
1983
+ return serialized(async () => {
1984
+ const saved = await chrome.storage.local.get([SITE_CHECKS_PENDING_KEY, SITE_CHECKS_APPROVALS_KEY]);
1985
+ const pending = Array.isArray(saved[SITE_CHECKS_PENDING_KEY]) ? saved[SITE_CHECKS_PENDING_KEY] : [];
1986
+ const request = pending.find((entry) => entry.origin === origin);
1987
+ if (!request || request.warnings.some((w) => !expected.includes(w.id)) || expected.some((id) => !request.warnings.some((w) => w.id === id))) {
1988
+ throw new Error("The warning changed. Review the current reasons and try again.");
1989
+ }
1990
+ const approvals2 = saved[SITE_CHECKS_APPROVALS_KEY] ?? {};
1991
+ const previous = Array.isArray(approvals2[origin]) ? approvals2[origin] : [];
1992
+ const next = pending.filter((entry) => entry.origin !== origin);
1993
+ await chrome.storage.local.set({
1994
+ [SITE_CHECKS_APPROVALS_KEY]: { ...approvals2, [origin]: [.../* @__PURE__ */ new Set([...previous, ...expected])] },
1995
+ [SITE_CHECKS_PENDING_KEY]: next
1996
+ });
1997
+ changed(next.length, false);
1998
+ });
1999
+ }
2000
+ function blockLocalSite(host, block) {
2001
+ return serialized(async () => {
2002
+ const saved = await chrome.storage.local.get([SITE_CHECKS_BLOCKED_KEY, SITE_CHECKS_APPROVALS_KEY, SITE_CHECKS_PENDING_KEY]);
2003
+ const list = Array.isArray(saved[SITE_CHECKS_BLOCKED_KEY]) ? saved[SITE_CHECKS_BLOCKED_KEY] : [];
2004
+ const next = list.filter((entry) => entry !== host);
2005
+ if (block) next.push(host);
2006
+ const approvals2 = { ...saved[SITE_CHECKS_APPROVALS_KEY] ?? {} };
2007
+ for (const origin of Object.keys(approvals2)) {
2008
+ if (new URL(origin).hostname === host) delete approvals2[origin];
2009
+ }
2010
+ const pending = Array.isArray(saved[SITE_CHECKS_PENDING_KEY]) ? saved[SITE_CHECKS_PENDING_KEY] : [];
2011
+ await chrome.storage.local.set({
2012
+ [SITE_CHECKS_BLOCKED_KEY]: next,
2013
+ [SITE_CHECKS_APPROVALS_KEY]: approvals2,
2014
+ [SITE_CHECKS_PENDING_KEY]: pending.filter((entry) => entry.host !== host)
2015
+ });
2016
+ await refreshSiteChecks();
2017
+ });
2018
+ }
2019
+ function revokeSiteWarning(origin) {
2020
+ return serialized(async () => {
2021
+ const saved = await chrome.storage.local.get(SITE_CHECKS_APPROVALS_KEY);
2022
+ const approvals2 = { ...saved[SITE_CHECKS_APPROVALS_KEY] ?? {} };
2023
+ delete approvals2[origin];
2024
+ await chrome.storage.local.set({ [SITE_CHECKS_APPROVALS_KEY]: approvals2 });
2025
+ });
2026
+ }
2027
+
1536
2028
  // extension/src/sw/browser-permissions.ts
1537
2029
  var initialization;
1538
2030
  function initializeBrowserPermissions() {
1539
2031
  return initialization ??= (async () => {
1540
2032
  await removeGlobalOrigins();
2033
+ await refreshSiteChecks();
1541
2034
  const saved = await chrome.storage.local.get([PERMISSIONS_MIGRATION_KEY, PROXY_STORAGE_KEY]);
1542
2035
  if (!saved[PERMISSIONS_MIGRATION_KEY]) {
1543
2036
  const permissions = ["cookies", "downloads"];
@@ -1548,17 +2041,22 @@
1548
2041
  })();
1549
2042
  }
1550
2043
  async function removeGlobalOrigins() {
2044
+ if (await keepAutomaticPermissions()) return;
1551
2045
  const all = await chrome.permissions.getAll();
1552
2046
  const origins = (all.origins ?? []).filter(isGlobalOrigin);
1553
2047
  if (origins.length) await chrome.permissions.remove({ origins });
1554
2048
  const remaining = await chrome.permissions.getAll();
1555
2049
  if (remaining.origins?.some(isGlobalOrigin)) throw new Error("Please remove all-site access in Chrome before continuing.");
1556
2050
  }
1557
- async function requireSitePermission(url) {
2051
+ async function requireSitePermission(url, tabId, frameId = 0) {
1558
2052
  await initializeBrowserPermissions();
1559
2053
  if (url === "about:blank") return;
2054
+ requireSiteChecksReady();
1560
2055
  const origin = originForUrl(url);
1561
- if (origin && await chrome.permissions.contains({ origins: [origin] })) return;
2056
+ if (origin && await chrome.permissions.contains({ origins: [origin] })) {
2057
+ await checkLocalSite(url, tabId, frameId);
2058
+ return;
2059
+ }
1562
2060
  throw new CmdError("POLICY_DENIED", blockedDomainMessage(hostOf(url) || url));
1563
2061
  }
1564
2062
  var requestWrite = Promise.resolve();
@@ -1648,7 +2146,7 @@
1648
2146
  async function targetTab(cmd) {
1649
2147
  return cmd.tabId ? parseTabId(cmd.tabId) : currentTabId();
1650
2148
  }
1651
- var TABLESS = /* @__PURE__ */ new Set(["tabs_list", "tab_new", "ping_probe", "download_file", "proxy_status"]);
2149
+ var TABLESS = /* @__PURE__ */ new Set(["tabs_list", "tab_new", "ping_probe", "site_check", "download_file", "proxy_status"]);
1652
2150
  async function resolveTab(cmd) {
1653
2151
  if (cmd.method === "download_file" && typeof cmd.params.url !== "string") return targetTab(cmd);
1654
2152
  if (TABLESS.has(cmd.method)) return null;
@@ -1867,11 +2365,11 @@
1867
2365
  chrome.debugger.detach({ tabId }).catch(() => void 0);
1868
2366
  });
1869
2367
  }
1870
- async function withDebugger(tabId, fn, timeoutMs = DEBUGGER_OP_MS) {
2368
+ async function withDebugger(tabId, fn, timeoutMs = DEBUGGER_OP_MS, retryDetached = true) {
1871
2369
  return locks.run(`dbg:${tabId}`, async () => {
1872
2370
  const target = { tabId };
1873
2371
  const once = async () => {
1874
- await requireSitePermission((await chrome.tabs.get(tabId)).url ?? "");
2372
+ await requireSitePermission((await chrome.tabs.get(tabId)).url ?? "", tabId);
1875
2373
  await ensureAttached(tabId);
1876
2374
  return fn(target);
1877
2375
  };
@@ -1879,7 +2377,7 @@
1879
2377
  return await withDeadline(tabId, once(), timeoutMs);
1880
2378
  } catch (err) {
1881
2379
  if (err instanceof CmdError && err.code === "TIMEOUT") throw err;
1882
- if (!DETACHED_RE.test(String(err?.message ?? err))) throw err;
2380
+ if (!retryDetached || !DETACHED_RE.test(String(err?.message ?? err))) throw err;
1883
2381
  forgetDebugger(tabId);
1884
2382
  return await withDeadline(tabId, once(), timeoutMs);
1885
2383
  } finally {
@@ -2092,6 +2590,7 @@
2092
2590
  "download_file",
2093
2591
  "upload_file",
2094
2592
  "ping_probe",
2593
+ "site_check",
2095
2594
  "frames_list",
2096
2595
  "observers",
2097
2596
  "print_pdf",
@@ -2194,8 +2693,15 @@
2194
2693
  const allowed = [];
2195
2694
  const denied2 = [];
2196
2695
  for (const f of seen) {
2197
- if (evaluatePolicy(f.url, cmd.method, policy).ok) allowed.push(f.frameId);
2198
- else denied2.push(f.url || `frame ${f.frameId}`);
2696
+ if (evaluatePolicy(f.url, cmd.method, policy).ok) {
2697
+ try {
2698
+ await requireSitePermission(f.url, tabId, f.frameId);
2699
+ allowed.push(f.frameId);
2700
+ } catch (err) {
2701
+ if (err instanceof CmdError && err.code === "POLICY_DENIED") denied2.push(f.url);
2702
+ else throw err;
2703
+ }
2704
+ } else denied2.push(f.url || `frame ${f.frameId}`);
2199
2705
  }
2200
2706
  if (allowed.length === 0) {
2201
2707
  throw new CmdError(
@@ -2224,7 +2730,9 @@
2224
2730
  case "tab_select": {
2225
2731
  const id = parseTabId(String(cmd.tabId));
2226
2732
  const t = await chrome.tabs.update(id, { active: true });
2227
- return tabInfo(t ?? await chrome.tabs.get(id));
2733
+ const selected = t ?? await chrome.tabs.get(id);
2734
+ await chrome.windows.update(selected.windowId, { focused: true });
2735
+ return tabInfo(selected);
2228
2736
  }
2229
2737
  case "tab_new": {
2230
2738
  const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
@@ -2438,43 +2946,57 @@
2438
2946
  });
2439
2947
  const pointCount = strokes.reduce((sum, stroke) => sum + stroke.length, 0);
2440
2948
  if (pointCount > 5e3) throw new CmdError("BAD_ARGS", "draw is limited to 5000 total points per call");
2441
- await withDebugger(id, async (t) => {
2442
- for (const stroke of strokes) {
2443
- const first = stroke[0];
2444
- await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", {
2445
- type: "mouseMoved",
2446
- x: first.x,
2447
- y: first.y,
2448
- buttons: 0
2449
- });
2450
- await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", {
2451
- type: "mousePressed",
2452
- x: first.x,
2453
- y: first.y,
2454
- button: "left",
2455
- buttons: 1,
2456
- clickCount: 1
2457
- });
2458
- for (const point of stroke.slice(1)) {
2459
- await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", {
2949
+ const budget = Math.max(1, Math.min(drawTimeoutMs(pointCount, strokes.length), cmd.timeoutMs) - 1e3);
2950
+ const deadline = Date.now() + budget;
2951
+ let completed = 0;
2952
+ try {
2953
+ await withDebugger(id, async (t) => {
2954
+ const sendMouse = async (params) => {
2955
+ const remaining = deadline - Date.now();
2956
+ if (remaining <= 0) throw new CmdError("TIMEOUT", "Drawing ran out of time");
2957
+ await withDeadline(id, chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", params), Math.min(DEBUGGER_OP_MS, remaining));
2958
+ };
2959
+ for (const stroke of strokes) {
2960
+ const first = stroke[0];
2961
+ await sendMouse({
2460
2962
  type: "mouseMoved",
2461
- x: point.x,
2462
- y: point.y,
2963
+ x: first.x,
2964
+ y: first.y,
2965
+ buttons: 0
2966
+ });
2967
+ await sendMouse({
2968
+ type: "mousePressed",
2969
+ x: first.x,
2970
+ y: first.y,
2971
+ button: "left",
2972
+ buttons: 1,
2973
+ clickCount: 1
2974
+ });
2975
+ for (const point of stroke.slice(1)) {
2976
+ await sendMouse({
2977
+ type: "mouseMoved",
2978
+ x: point.x,
2979
+ y: point.y,
2980
+ button: "left",
2981
+ buttons: 1
2982
+ });
2983
+ }
2984
+ const last = stroke[stroke.length - 1];
2985
+ await sendMouse({
2986
+ type: "mouseReleased",
2987
+ x: last.x,
2988
+ y: last.y,
2463
2989
  button: "left",
2464
- buttons: 1
2990
+ buttons: 0,
2991
+ clickCount: 1
2465
2992
  });
2993
+ completed++;
2466
2994
  }
2467
- const last = stroke[stroke.length - 1];
2468
- await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", {
2469
- type: "mouseReleased",
2470
- x: last.x,
2471
- y: last.y,
2472
- button: "left",
2473
- buttons: 0,
2474
- clickCount: 1
2475
- });
2476
- }
2477
- });
2995
+ }, budget, false);
2996
+ } catch (err) {
2997
+ const code = err instanceof CmdError ? err.code : "CDP_ERROR";
2998
+ throw new CmdError(code, `Drawing interrupted after ${completed} of ${strokes.length} complete strokes. Some marks may already be visible; inspect the canvas before continuing. ${err instanceof Error ? err.message : "Browser input failed."}`);
2999
+ }
2478
3000
  return { ok: true, strokes: strokes.length, points: pointCount };
2479
3001
  }
2480
3002
  case "type": {
@@ -2512,6 +3034,7 @@
2512
3034
  // Frame ids are re-probed per field too: an iframe that navigates
2513
3035
  // mid-batch must not keep a grant the policy would no longer give it.
2514
3036
  write: async (op) => {
3037
+ await requireSitePermission(await observedTabUrl(cmd, id), id);
2515
3038
  const out = await execOp(
2516
3039
  id,
2517
3040
  withWait(
@@ -2897,7 +3420,7 @@
2897
3420
  deps;
2898
3421
  async dispatch(cmd, send = this.deps.send) {
2899
3422
  const record = this.deps.activity?.();
2900
- const entry = cmd.method === "ping_probe" ? null : {
3423
+ const entry = cmd.method === "ping_probe" || cmd.method === "site_check" ? null : {
2901
3424
  id: crypto.randomUUID(),
2902
3425
  at: Date.now(),
2903
3426
  method: cmd.method,
@@ -2914,6 +3437,7 @@
2914
3437
  send({ type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data: { ok: true } });
2915
3438
  return;
2916
3439
  }
3440
+ await initializeBrowserPermissions();
2917
3441
  const policy = this.deps.getPolicy();
2918
3442
  if (!policy && cmd.method !== "ping_probe") {
2919
3443
  throw new CmdError(
@@ -2932,6 +3456,24 @@
2932
3456
  checkStopped?.();
2933
3457
  checkApproval();
2934
3458
  };
3459
+ if (cmd.method === "site_check" || isUrlGated(cmd.method) || cmd.method === "tab_new") requireSiteChecksReady();
3460
+ if (cmd.method === "site_check") {
3461
+ const url = typeof cmd.params.url === "string" ? cmd.params.url : "";
3462
+ const verdict = evaluatePolicy(url, "get_text", policy);
3463
+ if (!verdict.ok) {
3464
+ if (hostOf(url)) this.deps.onBlocked?.(hostOf(url), "get_text");
3465
+ throw new CmdError("POLICY_DENIED", verdict.reason);
3466
+ }
3467
+ try {
3468
+ await requireSitePermission(url);
3469
+ } catch (err) {
3470
+ if (hostOf(url)) this.deps.onBlocked?.(hostOf(url), "get_text");
3471
+ throw err;
3472
+ }
3473
+ checkAccess();
3474
+ send({ type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data: { checked: true } });
3475
+ return;
3476
+ }
2935
3477
  if (cmd.method === "ping_probe") {
2936
3478
  checkAccess();
2937
3479
  send({ type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data: {} });
@@ -2977,7 +3519,7 @@
2977
3519
  }
2978
3520
  if (isUrlGated(cmd.method) && url !== "about:blank") {
2979
3521
  try {
2980
- await requireSitePermission(url);
3522
+ await requireSitePermission(url, tab !== null && url === targetUrl ? tab : void 0);
2981
3523
  } catch (err) {
2982
3524
  const host = hostOf(url);
2983
3525
  if (host) this.deps.onBlocked?.(host, cmd.method);
@@ -3001,6 +3543,13 @@
3001
3543
  if (Number.isSafeInteger(id) && id >= 0) entry.tabId = id;
3002
3544
  if (opened?.url) entry.host = activityHost(opened.url) || entry.host;
3003
3545
  }
3546
+ if (siteChecksEnabled() && ["tab_new", "navigate", "back", "forward", "reload"].includes(cmd.method)) {
3547
+ const landedTab = cmd.method === "tab_new" ? entry?.tabId : tab;
3548
+ if (landedTab !== void 0 && landedTab !== null) {
3549
+ const landedUrl = (await chrome.tabs.get(landedTab)).url ?? "";
3550
+ if (/^https?:\/\//i.test(landedUrl)) await requireSitePermission(landedUrl, landedTab);
3551
+ }
3552
+ }
3004
3553
  if (border2) void this.markBorder(border2, cmd, tab, data);
3005
3554
  const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
3006
3555
  const tabUrl = await observedTabUrl(cmd, tab);
@@ -3014,7 +3563,10 @@
3014
3563
  send(frame);
3015
3564
  } catch (err) {
3016
3565
  const code = err instanceof CmdError ? err.code : "CDP_ERROR";
3017
- const message = err instanceof Error ? err.message : String(err);
3566
+ let message = err instanceof Error ? err.message : String(err);
3567
+ if (ran && message.startsWith("Suspicious website ")) {
3568
+ message = `Website access paused after the command started: ${message} The page may already be open; use tabs_list after approval.`;
3569
+ }
3018
3570
  if (entry) entry.outcome = ran && code === "POLICY_DENIED" ? "interrupted" : code === "POLICY_DENIED" ? "blocked" : "failed";
3019
3571
  this.deps.log(`command "${cmd.method}" failed: ${message}`);
3020
3572
  const frame = {
@@ -3044,6 +3596,21 @@
3044
3596
  }
3045
3597
  };
3046
3598
 
3599
+ // extension/src/sw/install-identity.ts
3600
+ var identity;
3601
+ function ensureInstallId() {
3602
+ return identity ??= (async () => {
3603
+ const { installId } = await chrome.storage.local.get("installId");
3604
+ if (typeof installId === "string" && installId.length > 0) return installId;
3605
+ const fresh = crypto.randomUUID();
3606
+ await chrome.storage.local.set({ installId: fresh });
3607
+ return fresh;
3608
+ })().catch((err) => {
3609
+ identity = void 0;
3610
+ throw err;
3611
+ });
3612
+ }
3613
+
3047
3614
  // extension/src/sw/observers.ts
3048
3615
  var SCRIPT_ID = "chrome-mcp-observers";
3049
3616
  var updates = Promise.resolve();
@@ -3055,7 +3622,7 @@
3055
3622
  try {
3056
3623
  await initializeBrowserPermissions();
3057
3624
  const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [SCRIPT_ID] }).catch(() => []);
3058
- const candidates = (policy ? observerMatches(policy) : []).flatMap((pattern) => pattern.startsWith("*://") ? [`http${pattern.slice(1)}`, `https${pattern.slice(1)}`] : [pattern]);
3625
+ const candidates = (policy && !policy.automaticSites ? observerMatches(policy) : []).flatMap((pattern) => pattern.startsWith("*://") ? [`http${pattern.slice(1)}`, `https${pattern.slice(1)}`] : [pattern]);
3059
3626
  const checks = await Promise.all(candidates.map(async (pattern) => await chrome.permissions.contains({ origins: [pattern] }) ? pattern : null));
3060
3627
  const wanted = checks.filter((pattern) => pattern !== null);
3061
3628
  if (wanted.length === 0) {
@@ -3143,7 +3710,7 @@
3143
3710
  return v && typeof v === "object" ? { ...v } : {};
3144
3711
  }
3145
3712
  var saveMarked = (m) => chrome.storage.session.set({ [KEY]: m }).catch(() => void 0);
3146
- async function enabled() {
3713
+ async function enabled2() {
3147
3714
  const { tabBorder } = await chrome.storage.local.get("tabBorder");
3148
3715
  return tabBorder !== false;
3149
3716
  }
@@ -3219,7 +3786,7 @@
3219
3786
  /** A command just ran in `tabId`: border it if it isn't already. */
3220
3787
  async used(tabId) {
3221
3788
  return this.locked(async () => {
3222
- if (!await enabled() || !await tabAllowed(tabId, this.getPolicy())) return;
3789
+ if (!await enabled2() || !await tabAllowed(tabId, this.getPolicy())) return;
3223
3790
  const marked = await loadMarked();
3224
3791
  if (marked[tabId]) return;
3225
3792
  marked[tabId] = await paint(tabId);
@@ -3229,7 +3796,7 @@
3229
3796
  /** A tab opened by tab_new: remember it, and paint once it loads. */
3230
3797
  async opened(tabId) {
3231
3798
  return this.locked(async () => {
3232
- if (!await enabled()) return;
3799
+ if (!await enabled2()) return;
3233
3800
  const marked = await loadMarked();
3234
3801
  marked[tabId] = marked[tabId] ?? "";
3235
3802
  await saveMarked(marked);
@@ -3242,7 +3809,7 @@
3242
3809
  return this.locked(async () => {
3243
3810
  const marked = await loadMarked();
3244
3811
  if (!(tabId in marked)) return;
3245
- if (!await enabled() || !await tabAllowed(tabId, this.getPolicy())) {
3812
+ if (!await enabled2() || !await tabAllowed(tabId, this.getPolicy())) {
3246
3813
  await unpaint(tabId, marked[tabId]);
3247
3814
  delete marked[tabId];
3248
3815
  } else {
@@ -3300,55 +3867,13 @@
3300
3867
  return checks.filter((site) => site !== null);
3301
3868
  }
3302
3869
 
3303
- // extension/src/connect.ts
3304
- var NATIVE_HOST = "com.mehmoodqureshi.mcp_browser_extension";
3305
- async function fetchPairing() {
3306
- if (typeof chrome.runtime.sendNativeMessage !== "function") {
3307
- return { ok: false, error: "restart_needed", message: "Finishing setup. Click Connect again if this stays." };
3308
- }
3309
- try {
3310
- const reply = await chrome.runtime.sendNativeMessage(NATIVE_HOST, { type: "pairing" });
3311
- return reply ?? { ok: false, message: "The helper gave no answer." };
3312
- } catch (err) {
3313
- const why = err instanceof Error ? err.message : String(err);
3314
- if (/not found|not registered|Specified native messaging host/i.test(why)) {
3315
- return {
3316
- ok: false,
3317
- error: "no_helper",
3318
- message: "Add the MCP Browser Extension server to your AI client (Claude Code, Cursor\u2026) and start it once; it sets this up. Then click Connect again."
3319
- };
3320
- }
3321
- return { ok: false, message: why };
3322
- }
3323
- }
3324
- async function applyPairing(reply) {
3325
- await chrome.storage.local.set({
3326
- wsPort: reply.port,
3327
- token: reply.token,
3328
- // The server's own --profile, as a suggestion only: another browser may
3329
- // already use that name, and taking it over would disconnect that one.
3330
- suggestedProfile: reply.profile ?? "",
3331
- pairingSource: "native"
3332
- });
3333
- }
3334
- async function requestPairingFromClick() {
3335
- let granted = false;
3336
- try {
3337
- granted = await chrome.permissions.request({ permissions: ["nativeMessaging"] });
3338
- } catch (err) {
3339
- return { ok: false, message: err instanceof Error ? err.message : String(err) };
3340
- }
3341
- if (!granted) return { ok: false, message: "Chrome needs that permission to fetch the pairing for you. You can also pair by hand in Settings." };
3342
- return fetchPairing();
3343
- }
3344
-
3345
3870
  // extension/src/sw/background.ts
3346
3871
  var PAIR_AFTER_RESTART_KEY = "pairAfterRestart";
3347
3872
  var commandsRunning = 0;
3348
3873
  var updateReady = false;
3349
3874
  var updateCheckedFor = "";
3350
3875
  function runningVersion() {
3351
- return true ? "0.9.35" : chrome.runtime.getManifest().version;
3876
+ return true ? "0.9.38" : chrome.runtime.getManifest().version;
3352
3877
  }
3353
3878
  function isNewer(a, b) {
3354
3879
  const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v) ?? []).slice(1).map(Number);
@@ -3383,11 +3908,13 @@
3383
3908
  var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
3384
3909
  var MAX_BLOCKED = 10;
3385
3910
  var currentPolicy = null;
3911
+ var serverSiteChecks = false;
3912
+ var effectiveSitePolicy = () => serverSiteChecks ? localSitePolicy(currentPolicy) : currentPolicy;
3386
3913
  var reportBlocked = (host, method) => {
3387
3914
  void rememberBlocked(host, method).catch(() => void 0);
3388
3915
  };
3389
- var executor = new ChromeExecutor(() => currentPolicy, reportBlocked);
3390
- var border = new TabBorder(() => currentPolicy);
3916
+ var executor = new ChromeExecutor(effectiveSitePolicy, reportBlocked);
3917
+ var border = new TabBorder(effectiveSitePolicy);
3391
3918
  var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 1e4];
3392
3919
  var reconnectAttempt = 0;
3393
3920
  var reconnectTimer = null;
@@ -3406,6 +3933,11 @@
3406
3933
  reconnectAttempt = 0;
3407
3934
  }
3408
3935
  var ws = new WsClient({
3936
+ onSiteChecks: (supported2) => {
3937
+ serverSiteChecks = supported2;
3938
+ setSiteChecksSupported(supported2);
3939
+ void chrome.storage.local.set({ siteChecksSupported: supported2 });
3940
+ },
3409
3941
  onCommand: (cmd, reply) => {
3410
3942
  commandsRunning++;
3411
3943
  void router.dispatch(cmd, reply).finally(() => {
@@ -3428,9 +3960,9 @@
3428
3960
  void chrome.storage.local.set({ allowedSites: [] });
3429
3961
  return;
3430
3962
  }
3431
- policy = { ...policy, allowDomains: policy.allowDomains.map(normalizeDomainPattern).filter(Boolean) };
3963
+ policy = { ...policy, automaticSites: false, blockedDomains: [], allowDomains: policy.allowDomains.map(normalizeDomainPattern).filter(Boolean) };
3432
3964
  currentPolicy = policy;
3433
- void syncObserverScript(policy, (m) => console.debug("[mcp-browser-extension]", m));
3965
+ void syncObserverScript(localSitePolicy(policy), (m) => console.debug("[mcp-browser-extension]", m));
3434
3966
  void onAllowlist(policy);
3435
3967
  },
3436
3968
  // Shown on the Options page, so you can tell which name this browser got.
@@ -3490,14 +4022,14 @@
3490
4022
  authorize: (cmd, verified) => approvals.authorize(cmd, verified),
3491
4023
  access: () => access.check(),
3492
4024
  activity: () => {
3493
- const generation = activity.token();
4025
+ const generation2 = activity.token();
3494
4026
  return (entry) => {
3495
- void activity.record(entry, generation).catch(historyFailed);
4027
+ void activity.record(entry, generation2).catch(historyFailed);
3496
4028
  };
3497
4029
  },
3498
4030
  exec: executor,
3499
4031
  send: (frame) => ws.send(frame),
3500
- getPolicy: () => currentPolicy,
4032
+ getPolicy: effectiveSitePolicy,
3501
4033
  onBlocked: reportBlocked,
3502
4034
  log: (m) => console.debug("[mcp-browser-extension]", m),
3503
4035
  border
@@ -3528,7 +4060,7 @@
3528
4060
  const res = await fetch(chrome.runtime.getURL("manifest.json"), { cache: "no-store" });
3529
4061
  if (!res.ok) return;
3530
4062
  const onDisk = await res.json();
3531
- const loaded = true ? "0.9.35" : chrome.runtime.getManifest().version;
4063
+ const loaded = true ? "0.9.38" : chrome.runtime.getManifest().version;
3532
4064
  if (typeof onDisk.version !== "string" || onDisk.version === loaded) return;
3533
4065
  const { reloadedFor } = await chrome.storage.local.get("reloadedFor");
3534
4066
  if (reloadedFor === onDisk.version) return;
@@ -3538,20 +4070,13 @@
3538
4070
  } catch {
3539
4071
  }
3540
4072
  }
3541
- async function ensureInstallId(installId) {
3542
- if (typeof installId === "string" && installId.length > 0) return installId;
3543
- const fresh = crypto.randomUUID();
3544
- await chrome.storage.local.set({ installId: fresh });
3545
- return fresh;
3546
- }
3547
4073
  async function getConfig() {
3548
- const { wsPort, token, profile, suggestedProfile, pairingSource, installId } = await chrome.storage.local.get([
4074
+ const { wsPort, token, profile, suggestedProfile, pairingSource } = await chrome.storage.local.get([
3549
4075
  "wsPort",
3550
4076
  "token",
3551
4077
  "profile",
3552
4078
  "suggestedProfile",
3553
- "pairingSource",
3554
- "installId"
4079
+ "pairingSource"
3555
4080
  ]);
3556
4081
  if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
3557
4082
  const label = typeof profile === "string" ? profile.trim() : "";
@@ -3562,23 +4087,30 @@
3562
4087
  token,
3563
4088
  profile: fromHelper ? "" : label,
3564
4089
  suggestedProfile: suggested || (fromHelper ? label : ""),
3565
- installId: await ensureInstallId(installId)
4090
+ installId: await ensureInstallId()
3566
4091
  };
3567
4092
  }
3568
4093
  return null;
3569
4094
  }
3570
4095
  async function onAllowlist(policy) {
3571
4096
  await initializeBrowserPermissions();
3572
- const effective = { ...policy, allowDomains: await permittedSites(policy.allowDomains) };
4097
+ const effective = { ...localSitePolicy(policy), allowDomains: await permittedSites(policy.allowDomains) };
3573
4098
  const { blockedSites } = await chrome.storage.local.get("blockedSites");
3574
4099
  const still = (Array.isArray(blockedSites) ? blockedSites : []).filter(
3575
- (b) => !isDomainAllowed(`https://${b.host}/`, effective)
4100
+ (b) => !siteChecksEnabled() && !isDomainAllowed(`https://${b.host}/`, effective)
3576
4101
  );
3577
4102
  await chrome.storage.local.set({ allowedSites: policy.allowDomains, blockedSites: still });
3578
4103
  pendingBlocked = still.length;
3579
4104
  reflectBadge(ws.state);
3580
4105
  }
3581
- async function rememberBlocked(host, method) {
4106
+ var blockedQueue = Promise.resolve();
4107
+ function rememberBlocked(host, method) {
4108
+ const next = blockedQueue.then(() => recordBlocked(host, method));
4109
+ blockedQueue = next.catch(() => void 0);
4110
+ return next;
4111
+ }
4112
+ async function recordBlocked(host, method) {
4113
+ if (siteChecksConfigured()) return;
3582
4114
  const { blockedSites, dismissedSites } = await chrome.storage.local.get(["blockedSites", "dismissedSites"]);
3583
4115
  const listed = Array.isArray(blockedSites) ? blockedSites : [];
3584
4116
  const prior = listed.filter((b) => b.host !== host);
@@ -3614,6 +4146,12 @@
3614
4146
  })().catch(() => void 0);
3615
4147
  });
3616
4148
  var pendingBlocked = 0;
4149
+ var pendingSiteWarnings = 0;
4150
+ onSiteWarnings((count, fresh) => {
4151
+ pendingSiteWarnings = count;
4152
+ reflectBadge(ws.state);
4153
+ if (fresh) void showPopup();
4154
+ });
3617
4155
  void chrome.storage.local.get("blockedSites").then(({ blockedSites }) => {
3618
4156
  pendingBlocked = Array.isArray(blockedSites) ? blockedSites.length : 0;
3619
4157
  });
@@ -3634,7 +4172,7 @@
3634
4172
  }
3635
4173
  };
3636
4174
  function reflectBadge(state) {
3637
- const b = access.stopped ? { text: "OFF", color: "#b91c1c", title: "MCP Browser Extension \u2014 access stopped" } : pendingClients > 0 ? { text: "?", color: "#b45309", title: "App connection awaiting approval. Open Settings to review." } : state === "connected" && pendingBlocked > 0 ? BLOCKED_BADGE : pendingFeatures > 0 ? { text: "?", color: "#b45309", title: "Permission needed. Open the extension popup \u2192 Extra permissions." } : BADGE[state] ?? BADGE.idle;
4175
+ const b = access.stopped ? { text: "OFF", color: "#b91c1c", title: "MCP Browser Extension \u2014 access stopped" } : pendingClients > 0 ? { text: "?", color: "#b45309", title: "App connection awaiting approval. Open Settings to review." } : state === "connected" && (pendingBlocked > 0 || pendingSiteWarnings > 0) ? BLOCKED_BADGE : pendingFeatures > 0 ? { text: "?", color: "#b45309", title: "Permission needed. Open the extension popup \u2192 Extra permissions." } : BADGE[state] ?? BADGE.idle;
3638
4176
  try {
3639
4177
  void chrome.action.setBadgeText({ text: b.text });
3640
4178
  void chrome.action.setBadgeBackgroundColor({ color: b.color });
@@ -3698,21 +4236,22 @@
3698
4236
  }
3699
4237
  syncProxyAuthListener();
3700
4238
  async function permissionsChanged() {
4239
+ await refreshSiteChecks();
3701
4240
  syncProxyAuthListener();
3702
4241
  await refreshFeatureBadge();
3703
4242
  if (currentPolicy) {
3704
4243
  await onAllowlist(currentPolicy);
3705
- await syncObserverScript(currentPolicy, (m) => console.debug("[mcp-browser-extension]", m));
4244
+ await syncObserverScript(localSitePolicy(currentPolicy), (m) => console.debug("[mcp-browser-extension]", m));
3706
4245
  }
3707
4246
  await border.clearAll();
3708
4247
  }
3709
4248
  var featureBadgeGeneration = 0;
3710
4249
  async function refreshFeatureBadge() {
3711
- const generation = ++featureBadgeGeneration;
4250
+ const generation2 = ++featureBadgeGeneration;
3712
4251
  const saved = (await chrome.storage.local.get(FEATURE_REQUESTS_KEY))[FEATURE_REQUESTS_KEY];
3713
4252
  const requested = Array.isArray(saved) ? saved.filter((p) => typeof p === "string" && p in OPTIONAL_FEATURES) : [];
3714
4253
  const missing = await Promise.all(requested.map(async (permission) => !await chrome.permissions.contains({ permissions: [permission] })));
3715
- if (generation !== featureBadgeGeneration) return;
4254
+ if (generation2 !== featureBadgeGeneration) return;
3716
4255
  const stillMissing = requested.filter((_, i) => missing[i]);
3717
4256
  pendingFeatures = stillMissing.length;
3718
4257
  reflectBadge(ws.state);
@@ -3728,6 +4267,9 @@
3728
4267
  void permissionsChanged().catch((err) => console.warn("[mcp-browser-extension] permission change failed", err));
3729
4268
  });
3730
4269
  chrome.storage.onChanged.addListener((changes, area) => {
4270
+ if (area === "local" && (changes[SITE_CHECKS_KEY] || changes[SITE_CHECKS_BLOCKED_KEY] || changes[SITE_CHECKS_PENDING_KEY])) {
4271
+ void permissionsChanged();
4272
+ }
3731
4273
  if (area === "local" && changes[FEATURE_REQUESTS_KEY]) void refreshFeatureBadge();
3732
4274
  if (area === "local" && changes.tabBorder?.newValue === false) void border.clearAll();
3733
4275
  if (area === "local" && changes[PROXY_STORAGE_KEY]) void syncProxy();
@@ -3739,6 +4281,61 @@
3739
4281
  }
3740
4282
  });
3741
4283
  chrome.runtime.onMessage.addListener((msg, sender, reply) => {
4284
+ if (["site_checks_mode", "site_warning_allow", "site_warning_revoke", "site_block"].includes(msg?.type)) {
4285
+ if (!isApprovalPage(sender) || access.stopped || !ws.isConnected()) {
4286
+ reply({ ok: false, error: "Use the connected extension popup or Settings to change website access." });
4287
+ return;
4288
+ }
4289
+ let operation;
4290
+ if (msg.type === "site_checks_mode" && typeof msg.allow === "boolean") {
4291
+ if (msg.allow && !serverSiteChecks) {
4292
+ reply({ ok: false, error: "Update and restart the connected MCP server first." });
4293
+ return;
4294
+ }
4295
+ operation = changeSiteChecks(msg.allow);
4296
+ } else if (msg.type === "site_warning_allow" && typeof msg.origin === "string" && Array.isArray(msg.reasons) && msg.reasons.length <= 32 && msg.reasons.every((id) => typeof id === "string")) {
4297
+ operation = allowSiteWarning(msg.origin, msg.reasons);
4298
+ } else if (msg.type === "site_warning_revoke" && typeof msg.origin === "string") {
4299
+ operation = revokeSiteWarning(msg.origin);
4300
+ } else if (msg.type === "site_block" && typeof msg.host === "string" && typeof msg.block === "boolean" && siteOrigins(msg.host).length) {
4301
+ operation = blockLocalSite(normalizeDomainPattern(msg.host), msg.block);
4302
+ } else {
4303
+ reply({ ok: false, error: "Invalid website access request." });
4304
+ return;
4305
+ }
4306
+ void operation.then(async () => {
4307
+ if (currentPolicy) await onAllowlist(currentPolicy);
4308
+ ws.send({ type: "approval_changed", v: PROTOCOL_VERSION });
4309
+ reply({ ok: true });
4310
+ }, (err) => reply({ ok: false, error: err instanceof Error ? err.message : "Could not save website access." })).finally(() => {
4311
+ if (msg.type === "site_checks_mode" && msg.allow) void reopenPopup(sender);
4312
+ });
4313
+ return true;
4314
+ }
4315
+ if (msg?.type === "phone_pairing") {
4316
+ if (!isApprovalPage(sender) || !PHONE_ACTIONS.includes(msg.action)) {
4317
+ reply({ ok: false, message: "Use Pair phone in the extension popup." });
4318
+ return;
4319
+ }
4320
+ const permission = msg.action === "create" ? chrome.permissions.request({ permissions: ["nativeMessaging"] }) : chrome.permissions.contains({ permissions: ["nativeMessaging"] });
4321
+ void permission.then(async (granted) => {
4322
+ if (!granted) {
4323
+ reply({ ok: false, message: "Click Generate QR code to allow phone pairing." });
4324
+ return;
4325
+ }
4326
+ if (typeof chrome.runtime.sendNativeMessage !== "function" && msg.action === "create") {
4327
+ reply({ ok: false, message: "Permission saved. Reopen the extension and click Pair phone." });
4328
+ setTimeout(() => chrome.runtime.reload(), 200);
4329
+ return;
4330
+ }
4331
+ reply(await phonePairingAction(msg.action, msg.id, {
4332
+ approvals,
4333
+ stopped: () => access.stopped,
4334
+ connected: () => ws.isConnected()
4335
+ }));
4336
+ }).catch(() => reply({ ok: false, message: "Could not open phone pairing. Try again." }));
4337
+ return true;
4338
+ }
3742
4339
  if (msg?.type === "connect_with_click") {
3743
4340
  if (!isApprovalPage(sender) || access.stopped) {
3744
4341
  reply({ ok: false, message: "Use Resume connection in the extension popup or Settings first." });
@@ -3774,9 +4371,9 @@
3774
4371
  return;
3775
4372
  }
3776
4373
  const operation = msg.allow ? chrome.permissions.request({ permissions: [msg.feature] }) : chrome.permissions.remove({ permissions: [msg.feature] });
3777
- void operation.then((changed) => reply({
3778
- ok: changed || !msg.allow,
3779
- ...!changed && msg.allow ? { error: "No access was added. You can allow it whenever you are ready." } : {}
4374
+ void operation.then((changed2) => reply({
4375
+ ok: changed2 || !msg.allow,
4376
+ ...!changed2 && msg.allow ? { error: "No access was added. You can allow it whenever you are ready." } : {}
3780
4377
  }), () => reply({ ok: false, error: "Could not change this permission. Please try again." })).finally(() => {
3781
4378
  if (msg.allow) void reopenPopup(sender);
3782
4379
  });
@@ -3848,7 +4445,8 @@
3848
4445
  reply({ ok: false, error: "Please click Allow for this website and accept Chrome\u2019s permission request." });
3849
4446
  return;
3850
4447
  }
3851
- if (!msg.allow) await chrome.permissions.remove({ origins });
4448
+ if (siteChecksEnabled()) await blockLocalSite(normalizeDomainPattern(msg.host), !msg.allow);
4449
+ else if (!msg.allow) await chrome.permissions.remove({ origins });
3852
4450
  if (!ws.isConnected() || msg.allow && access.stopped) {
3853
4451
  reply({ ok: false, error: "Access is stopped or disconnected. Reconnect before allowing this website." });
3854
4452
  return;