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
package/README.md CHANGED
@@ -53,12 +53,12 @@ Chrome, so a page the agent opens is the page you would see.
53
53
  Other tools drive a signed-in Chrome too (the `hangwin/mcp-chrome` extension,
54
54
  for one), so the question is what you get on top of the session:
55
55
 
56
- - **A per-domain allowlist, deny-all by default.** With no flags the agent can
57
- read nothing. `--allow-domain mail.google.com --allow-domain github.com`
58
- opens exactly those hosts (`*.example.com` covers a domain and its
59
- subdomains); every other site is refused before the call reaches the page.
60
- Reads are gated, not only clicks, and the extension re-checks the same policy
61
- on its side. Mutations, eval, downloads and uploads are separate opt-ins.
56
+ - **Website checks with explicit Chrome permission.** Local suspicious-site
57
+ checks are on by default. After one Chrome permission grant, ordinary sites
58
+ work automatically and flagged sites need Allow. Named-site approval remains
59
+ available; `--no-site-grants` enforces the server's fixed allowlist. Reads and
60
+ actions are checked in the extension. Mutations, eval, downloads and uploads
61
+ are separate opt-ins.
62
62
  - **Password values are never returned**, and `--redact` scrubs tokens, API
63
63
  keys and JWTs out of page reads.
64
64
  - **An audit trail**: every call is logged with the URL, the allow/deny
@@ -142,10 +142,11 @@ with `/mcp` inside a session — no restart needed.
142
142
 
143
143
  </details>
144
144
 
145
- By default everything is **deny-all** (no domains, no eval, no mutations). Grant
146
- exactly what you need with `--allow-domain <glob>` (repeatable), `--enable-mutations`,
147
- `--enable-downloads`, `--enable-uploads`, or `--unsafe-enable-eval`. All-site access
148
- is not supported; approve named websites in the Chrome extension popup.
145
+ Website access requires app approval and Chrome permission. Suspicious-site
146
+ checks are on by default; click **Allow website access** once in the extension.
147
+ For a fixed named-site policy, use `--no-site-grants --allow-domain <glob>`.
148
+ Mutations, downloads, uploads and eval stay off until enabled with
149
+ `--enable-mutations`, `--enable-downloads`, `--enable-uploads` or `--unsafe-enable-eval`.
149
150
 
150
151
  Email verification is on by default. For a trusted, local unattended setup, you
151
152
  can opt out with `--no-email-verification` (or set
@@ -224,7 +225,22 @@ connection creates a new key and needs a new approval. **Remove access** blocks
224
225
  commands from that key; an action already running may finish. YOLO, bypass-permission
225
226
  mode and `--no-email-verification` cannot skip this extension check.
226
227
 
227
- **6. Allow the websites you need.** The popup shows a Website access request.
228
+ **6. Allow website access once.** Suspicious-site checks are on by default. In the
229
+ popup or Settings, **Allow website access** asks Chrome for HTTP/HTTPS access. After
230
+ you accept, ordinary sites work without an Allow request. Local checks pause
231
+ flagged addresses and risky login forms and show their reasons under **Waiting
232
+ for Allow**. Allow approves only the displayed reasons for that exact origin;
233
+ new reasons ask again. **Blocked websites** lets you block or restore any site.
234
+ Both the extension and connected server must be updated; `--no-site-grants`
235
+ disables this option.
236
+
237
+ These checks run locally with no external checking service. They can miss scams
238
+ and flag legitimate sites. Form checks run after a page loads, before app reads
239
+ or actions; they do not prevent page scripts from running or detect all malware.
240
+ App approval, Stop access and the configured tool permissions still apply.
241
+
242
+ Choose **Use per-site approval** or **Turn off** to use named-site access instead.
243
+ With automatic access off, the popup shows a Website access request.
228
244
  Click **Allow**, then accept Chrome's website permission prompt if shown. This
229
245
  single flow grants that named website in both Chrome and the server's allowlist.
230
246
  Allowing a website lets approved apps read and take actions there, including
@@ -707,17 +723,19 @@ status badge, and a stable pairing token (`--persist-token`).
707
723
 
708
724
  ## Security posture (default)
709
725
 
710
- **Deny-all safe mode.** With no policy configured: empty domain allowlist,
711
- `eval` off, downloads off, mutating tools off. Opt in explicitly:
726
+ **Access requires consent.** The server starts with an empty named-site allowlist;
727
+ the extension's default suspicious-site mode requires an explicit Chrome host
728
+ grant before ordinary sites work automatically. Eval, downloads and mutations
729
+ stay off until enabled. Named-site policy remains available:
712
730
 
713
731
  ```
714
- mcp-browser-extension --allow-domain example.com --enable-mutations
732
+ mcp-browser-extension --no-site-grants --allow-domain example.com --enable-mutations
715
733
  mcp-browser-extension --policy ./policy.json # see policy.example.json
716
734
  mcp-browser-extension --enable-observers # console/network/dialog capture (patches page globals)
717
735
  mcp-browser-extension --redact # scrub secret-shaped strings out of page reads
718
736
  ```
719
737
 
720
- **Allowing a site without a restart.** When a call is refused because its site
738
+ **Named-site mode: allowing a site without a restart.** When a call is refused because its site
721
739
  is not allowed, the extension's toolbar icon shows a `?`. Click it: the popup
722
740
  lists the blocked site with an **Allow** button, shows whether the site you are on
723
741
  is allowed (with its own Allow), and lists the allowed sites. The Options page
@@ -14,6 +14,8 @@ export interface ClientProof {
14
14
  signature: string;
15
15
  }
16
16
  export interface ClientConnection {
17
+ kind?: 'phone';
18
+ phoneId?: string;
17
19
  id: string;
18
20
  publicKey: string;
19
21
  name: string;
@@ -0,0 +1,35 @@
1
+ /** Versioned contract shared by the extension and the future phone client. */
2
+ export declare const PHONE_PAIRING_VERSION = 1;
3
+ export declare const PHONE_INVITATION_MS: number;
4
+ export declare const PHONE_ACCESS_MS: number;
5
+ export declare const PHONE_MAX_DEVICES = 10;
6
+ export interface PhoneDevice {
7
+ id: string;
8
+ name: string;
9
+ clientId: string;
10
+ publicKey: string;
11
+ requestedAt: number;
12
+ expiresAt: number;
13
+ state: 'pending' | 'paired';
14
+ confirmation: string;
15
+ /** Added by Chrome when presenting a saved pairing to the person. */
16
+ browserApproved?: boolean;
17
+ }
18
+ export interface PhonePairingState {
19
+ online: boolean;
20
+ profile: string;
21
+ invitation?: {
22
+ payload: string;
23
+ expiresAt: number;
24
+ };
25
+ devices: PhoneDevice[];
26
+ }
27
+ export type PhoneAction = 'state' | 'create' | 'cancel' | 'approve' | 'deny' | 'remove';
28
+ export declare const PHONE_ACTIONS: readonly PhoneAction[];
29
+ export type PhoneReply = {
30
+ ok: true;
31
+ state: PhonePairingState;
32
+ } | {
33
+ ok: false;
34
+ message: string;
35
+ };
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PHONE_ACTIONS = exports.PHONE_MAX_DEVICES = exports.PHONE_ACCESS_MS = exports.PHONE_INVITATION_MS = exports.PHONE_PAIRING_VERSION = void 0;
4
+ /** Versioned contract shared by the extension and the future phone client. */
5
+ exports.PHONE_PAIRING_VERSION = 1;
6
+ exports.PHONE_INVITATION_MS = 2 * 60_000;
7
+ exports.PHONE_ACCESS_MS = 30 * 24 * 60 * 60_000;
8
+ exports.PHONE_MAX_DEVICES = 10;
9
+ exports.PHONE_ACTIONS = ['state', 'create', 'cancel', 'approve', 'deny', 'remove'];
10
+ //# sourceMappingURL=phone-pairing.js.map
@@ -133,6 +133,9 @@ function isDomainAllowed(url, policy) {
133
133
  const host = hostOf(url);
134
134
  if (!host)
135
135
  return false;
136
+ if (policy.automaticSites && /^https?:\/\//i.test(url)) {
137
+ return !(policy.blockedDomains ?? []).some((pat) => globMatches(host, pat));
138
+ }
136
139
  return policy.allowDomains.some((pat) => globMatches(host, pat));
137
140
  }
138
141
  /**
@@ -177,6 +180,10 @@ function evaluatePolicy(url, method, policy) {
177
180
  return { ok: true };
178
181
  if (!isDomainAllowed(url, policy)) {
179
182
  const host = hostOf(url) || url;
183
+ if (policy.automaticSites && /^https?:\/\//i.test(url))
184
+ return {
185
+ ok: false, reason: `Access to ${host} was removed. Restore it under Blocked websites in extension Settings.`,
186
+ };
180
187
  return { ok: false, reason: blockedDomainMessage(host) };
181
188
  }
182
189
  return { ok: true };
@@ -24,6 +24,8 @@ export type ProtocolVersion = typeof PROTOCOL_VERSION;
24
24
  * supported because the extension re-reads the handshake on every failed dial.
25
25
  */
26
26
  export declare const DEFAULT_WS_PORT: 38017;
27
+ /** Maximum encoded JSON frame, shared by the receiver and result sender. */
28
+ export declare const MAX_BRIDGE_FRAME_BYTES: number;
27
29
  /**
28
30
  * The Chrome Web Store copy of the extension. Chrome updates that copy on its
29
31
  * own; any other id is an unpacked (or self-packed) copy, which only changes
@@ -48,6 +50,8 @@ export declare const CLOSE_SUPERSEDED: 4000;
48
50
  * every call. Without it, the server falls back to fetching the URL itself.
49
51
  */
50
52
  export declare const WIRE_CAP_TAB_URL: "tab-url";
53
+ /** Signed local website checks, including the browser's automatic-access setting. */
54
+ export declare const WIRE_CAP_SITE_CHECKS: "site-checks-v1";
51
55
  /**
52
56
  * `fill-form`: this extension handles the `fill_form` wire method — every field
53
57
  * of a form written in ONE round-trip. Without it the server fills field by
@@ -100,6 +104,9 @@ export declare const FILL_FORM_MAX_TIMEOUT_MS = 600000;
100
104
  * grows with the field count (10 s base + 6 s per field), clamped to 60 s..10 min.
101
105
  */
102
106
  export declare function fillFormTimeoutMs(fields: number): number;
107
+ /** Drawing sends one mouse event per point plus press/release events per stroke.
108
+ * Budget the whole drawing separately from the deadline for one stalled event. */
109
+ export declare function drawTimeoutMs(points: number, strokes: number): number;
103
110
  /**
104
111
  * `fill_form` result. Ops run in order and stop at the first failure, so
105
112
  * `filled` counts the fields that landed and `error` names the one that did not.
@@ -124,10 +131,10 @@ export interface DrawStroke {
124
131
  * `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
125
132
  * responsiveness check used to detect a dead-but-not-yet-reconnected worker).
126
133
  */
127
- export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'draw' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'proxy_status' | 'ping_probe';
134
+ export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'draw' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'proxy_status' | 'ping_probe' | 'site_check';
128
135
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
129
136
  export declare const WIRE_METHODS: readonly WireMethod[];
130
- export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
137
+ export type ExecutorErrorCode = 'RESULT_TOO_LARGE' | 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
131
138
  export interface BaseFrame {
132
139
  type: string;
133
140
  v: ProtocolVersion;
@@ -168,6 +175,9 @@ export interface HelloFrame extends BaseFrame {
168
175
  */
169
176
  export interface WirePolicy {
170
177
  allowDomains: string[];
178
+ /** Extension-local overlay only; server configuration cannot opt a browser in. */
179
+ automaticSites?: boolean;
180
+ blockedDomains?: string[];
171
181
  allowEval: boolean;
172
182
  allowDownloads: boolean;
173
183
  allowUploads: boolean;
@@ -185,6 +195,7 @@ export interface WirePolicy {
185
195
  }
186
196
  export interface WelcomeFrame extends BaseFrame {
187
197
  type: 'welcome';
198
+ siteChecks?: boolean;
188
199
  serverVersion: string;
189
200
  sessionId: string;
190
201
  heartbeatMs: number;
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
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;
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_SITE_CHECKS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.STORE_EXTENSION_URL = exports.STORE_EXTENSION_ID = exports.MAX_BRIDGE_FRAME_BYTES = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
4
4
  exports.fillFormTimeoutMs = fillFormTimeoutMs;
5
+ exports.drawTimeoutMs = drawTimeoutMs;
5
6
  /**
6
7
  * shared/protocol.ts — THE SINGLE SOURCE OF TRUTH for the wire contract.
7
8
  *
@@ -26,6 +27,8 @@ exports.PROTOCOL_VERSION = 1;
26
27
  * supported because the extension re-reads the handshake on every failed dial.
27
28
  */
28
29
  exports.DEFAULT_WS_PORT = 38017;
30
+ /** Maximum encoded JSON frame, shared by the receiver and result sender. */
31
+ exports.MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
29
32
  /**
30
33
  * The Chrome Web Store copy of the extension. Chrome updates that copy on its
31
34
  * own; any other id is an unpacked (or self-packed) copy, which only changes
@@ -50,6 +53,8 @@ exports.CLOSE_SUPERSEDED = 4000;
50
53
  * every call. Without it, the server falls back to fetching the URL itself.
51
54
  */
52
55
  exports.WIRE_CAP_TAB_URL = 'tab-url';
56
+ /** Signed local website checks, including the browser's automatic-access setting. */
57
+ exports.WIRE_CAP_SITE_CHECKS = 'site-checks-v1';
53
58
  /**
54
59
  * `fill-form`: this extension handles the `fill_form` wire method — every field
55
60
  * of a form written in ONE round-trip. Without it the server fills field by
@@ -79,6 +84,12 @@ function fillFormTimeoutMs(fields) {
79
84
  const n = Number.isFinite(fields) && fields > 0 ? Math.floor(fields) : 0;
80
85
  return Math.min(exports.FILL_FORM_MAX_TIMEOUT_MS, Math.max(exports.FILL_FORM_MIN_TIMEOUT_MS, 10_000 + n * exports.FILL_FORM_PER_FIELD_MS));
81
86
  }
87
+ /** Drawing sends one mouse event per point plus press/release events per stroke.
88
+ * Budget the whole drawing separately from the deadline for one stalled event. */
89
+ function drawTimeoutMs(points, strokes) {
90
+ const events = Math.max(0, points) + 2 * Math.max(0, strokes);
91
+ return Math.min(240_000, Math.max(30_000, 10_000 + events * 60));
92
+ }
82
93
  /** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
83
94
  exports.WIRE_METHODS = [
84
95
  'tabs_list',
@@ -112,5 +123,6 @@ exports.WIRE_METHODS = [
112
123
  'fill_form',
113
124
  'proxy_status',
114
125
  'ping_probe',
126
+ 'site_check',
115
127
  ];
116
128
  //# sourceMappingURL=protocol.js.map
@@ -0,0 +1,31 @@
1
+ /** Local warning rules. An empty result means no rule matched, never a safety guarantee. */
2
+ export declare const SITE_CHECKS_KEY = "localSiteChecksV1";
3
+ export declare const SITE_CHECKS_PENDING_KEY = "localSiteWarnings";
4
+ export declare const SITE_CHECKS_APPROVALS_KEY = "localSiteWarningApprovals";
5
+ export declare const SITE_CHECKS_BLOCKED_KEY = "localBlockedSites";
6
+ export declare const AUTOMATIC_SITE_ORIGINS: string[];
7
+ export interface SiteWarning {
8
+ id: string;
9
+ reason: string;
10
+ }
11
+ export interface SiteWarningRequest {
12
+ origin: string;
13
+ host: string;
14
+ warnings: SiteWarning[];
15
+ at: number;
16
+ }
17
+ export interface LoginSignals {
18
+ url: string;
19
+ title: string;
20
+ passwords: number;
21
+ forms: Array<{
22
+ action: string;
23
+ method: string;
24
+ }>;
25
+ }
26
+ export declare function isLocalDevelopmentHost(host: string): boolean;
27
+ export declare function siteUrlWarnings(input: string): SiteWarning[];
28
+ export declare function loginWarnings(signals: LoginSignals): SiteWarning[];
29
+ export declare function uniqueWarnings(warnings: SiteWarning[]): SiteWarning[];
30
+ /** Self-contained isolated-world reader. Never reads field values or page text. */
31
+ export declare function collectLoginSignals(): LoginSignals;
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTOMATIC_SITE_ORIGINS = exports.SITE_CHECKS_BLOCKED_KEY = exports.SITE_CHECKS_APPROVALS_KEY = exports.SITE_CHECKS_PENDING_KEY = exports.SITE_CHECKS_KEY = void 0;
4
+ exports.isLocalDevelopmentHost = isLocalDevelopmentHost;
5
+ exports.siteUrlWarnings = siteUrlWarnings;
6
+ exports.loginWarnings = loginWarnings;
7
+ exports.uniqueWarnings = uniqueWarnings;
8
+ exports.collectLoginSignals = collectLoginSignals;
9
+ /** Local warning rules. An empty result means no rule matched, never a safety guarantee. */
10
+ exports.SITE_CHECKS_KEY = 'localSiteChecksV1';
11
+ exports.SITE_CHECKS_PENDING_KEY = 'localSiteWarnings';
12
+ exports.SITE_CHECKS_APPROVALS_KEY = 'localSiteWarningApprovals';
13
+ exports.SITE_CHECKS_BLOCKED_KEY = 'localBlockedSites';
14
+ exports.AUTOMATIC_SITE_ORIGINS = ['http://*/*', 'https://*/*'];
15
+ const BRANDS = {
16
+ 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'],
17
+ microsoft: ['microsoft.com', 'microsoftonline.com', 'live.com', 'office.com', 'outlook.com'],
18
+ apple: ['apple.com', 'icloud.com'],
19
+ paypal: ['paypal.com'],
20
+ amazon: ['amazon.com', 'amazon.co.uk', 'amazon.de', 'amazon.fr', 'amazon.in', 'amazon.ca', 'amazon.com.au', 'amazon.co.jp', 'amazon.es', 'amazon.it'],
21
+ github: ['github.com', 'github.dev'],
22
+ facebook: ['facebook.com', 'meta.com'],
23
+ instagram: ['instagram.com', 'meta.com'],
24
+ linkedin: ['linkedin.com'],
25
+ dropbox: ['dropbox.com'],
26
+ coinbase: ['coinbase.com'],
27
+ };
28
+ const AUTH_WORD = /(?:^|[.\-])(login|signin|sign-in|verify|verification|secure|account|password|recover|support)(?:[.\-]|$)/;
29
+ function hostIn(host, domains) {
30
+ return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
31
+ }
32
+ /** RFC 3492 decoding, bounded by DNS label length. Invalid labels remain unchanged. */
33
+ function unicodeLabel(label) {
34
+ if (!label.startsWith('xn--') || label.length > 63)
35
+ return label;
36
+ try {
37
+ const input = label.slice(4);
38
+ const split = input.lastIndexOf('-');
39
+ const points = split < 0 ? [] : Array.from(input.slice(0, split), (c) => c.codePointAt(0));
40
+ let pos = split < 0 ? 0 : split + 1;
41
+ let n = 128, i = 0, bias = 72;
42
+ while (pos < input.length) {
43
+ const old = i;
44
+ let weight = 1;
45
+ for (let k = 36;; k += 36) {
46
+ const code = input.charCodeAt(pos++);
47
+ const digit = code >= 97 && code <= 122 ? code - 97 : code >= 48 && code <= 57 ? code - 22 : 36;
48
+ if (digit >= 36 || !Number.isSafeInteger(i + digit * weight))
49
+ return label;
50
+ i += digit * weight;
51
+ const t = k <= bias ? 1 : k >= bias + 26 ? 26 : k - bias;
52
+ if (digit < t)
53
+ break;
54
+ weight *= 36 - t;
55
+ }
56
+ const size = points.length + 1;
57
+ let delta = Math.floor((i - old) / (old === 0 ? 700 : 2));
58
+ delta += Math.floor(delta / size);
59
+ let k = 0;
60
+ while (delta > 455) {
61
+ delta = Math.floor(delta / 35);
62
+ k += 36;
63
+ }
64
+ bias = k + Math.floor(36 * delta / (delta + 38));
65
+ n += Math.floor(i / size);
66
+ i %= size;
67
+ if (n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff))
68
+ return label;
69
+ points.splice(i++, 0, n);
70
+ }
71
+ return String.fromCodePoint(...points);
72
+ }
73
+ catch {
74
+ return label;
75
+ }
76
+ }
77
+ /** A deliberately small confusable set for these rules, not a full Unicode classifier. */
78
+ function skeleton(label) {
79
+ const lookalikes = {
80
+ 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'у': 'y', 'х': 'x',
81
+ 'і': 'i', 'ј': 'j', 'ӏ': 'l', 'ѕ': 's', 'ԁ': 'd', 'ԛ': 'q',
82
+ 'α': 'a', 'ο': 'o', 'ρ': 'p', 'ν': 'v', 'ι': 'i', '0': 'o', '1': 'l',
83
+ };
84
+ return Array.from(unicodeLabel(label).toLowerCase(), (c) => lookalikes[c] ?? c).join('');
85
+ }
86
+ function isLocalDevelopmentHost(host) {
87
+ return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(host);
88
+ }
89
+ function siteUrlWarnings(input) {
90
+ let url;
91
+ try {
92
+ url = new URL(input);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ if (!/^https?:$/.test(url.protocol))
98
+ return [];
99
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
100
+ const warnings = [];
101
+ if (url.username && /[.]/.test(decodeURIComponentSafe(url.username))) {
102
+ warnings.push({ id: 'hidden-destination', reason: `The address puts a website name before @. The actual destination is ${host}.` });
103
+ }
104
+ const labels = host.split('.');
105
+ for (const [brand, domains] of Object.entries(BRANDS)) {
106
+ if (hostIn(host, domains))
107
+ continue;
108
+ if (domains.some((domain) => host.includes(`${domain}.`))) {
109
+ warnings.push({ id: `misleading-domain:${brand}`, reason: `The address includes ${brand}'s domain but actually belongs to ${host}.` });
110
+ continue;
111
+ }
112
+ if (labels.some((label) => label !== brand && skeleton(label) === brand)) {
113
+ warnings.push({ id: `lookalike:${brand}`, reason: `The address uses lookalike characters to resemble ${brand}.` });
114
+ continue;
115
+ }
116
+ // A brand in an article path or an ordinary unrelated domain is not enough.
117
+ if (labels.some((label) => label.split('-').includes(brand)) && AUTH_WORD.test(host)) {
118
+ warnings.push({ id: `brand-login:${brand}`, reason: `The address combines ${brand}'s name with login or account wording outside its recognised domains.` });
119
+ }
120
+ }
121
+ return warnings;
122
+ }
123
+ function decodeURIComponentSafe(value) {
124
+ try {
125
+ return decodeURIComponent(value);
126
+ }
127
+ catch {
128
+ return value;
129
+ }
130
+ }
131
+ function loginWarnings(signals) {
132
+ const warnings = siteUrlWarnings(signals.url);
133
+ if (!signals.passwords)
134
+ return warnings;
135
+ let page;
136
+ try {
137
+ page = new URL(signals.url);
138
+ }
139
+ catch {
140
+ return warnings;
141
+ }
142
+ if (!/^https?:$/.test(page.protocol))
143
+ return warnings;
144
+ const local = isLocalDevelopmentHost(page.hostname);
145
+ if (page.protocol === 'http:' && !local)
146
+ warnings.push({
147
+ id: 'unencrypted-login', reason: 'This page asks for a password over an unencrypted HTTP connection.',
148
+ });
149
+ for (const form of signals.forms.slice(0, 32)) {
150
+ let action;
151
+ try {
152
+ action = new URL(form.action || signals.url, signals.url);
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ if (action.protocol === 'http:' && !(local && isLocalDevelopmentHost(action.hostname)))
158
+ warnings.push({
159
+ id: `unencrypted-password-destination:${action.origin}`, reason: `A password form sends to an unencrypted address at ${action.hostname}.`,
160
+ });
161
+ if (form.method === 'get')
162
+ warnings.push({
163
+ id: 'password-in-address', reason: 'A password form is configured to put its fields in the website address.',
164
+ });
165
+ }
166
+ const title = signals.title.slice(0, 300).toLowerCase();
167
+ if (/\b(sign[ -]?in|log[ -]?in|verify|account|password)\b/.test(title)) {
168
+ for (const [brand, domains] of Object.entries(BRANDS)) {
169
+ if (new RegExp(`\\b${brand}\\b`).test(title) && !hostIn(page.hostname, domains)) {
170
+ // Mere "Sign in with X" buttons without password fields are never enough.
171
+ warnings.push({ id: `login-brand:${brand}`, reason: `This password page presents itself as ${brand}, but its address is ${page.hostname}.` });
172
+ }
173
+ }
174
+ }
175
+ return uniqueWarnings(warnings);
176
+ }
177
+ function uniqueWarnings(warnings) {
178
+ return [...new Map(warnings.map((warning) => [warning.id, warning])).values()];
179
+ }
180
+ /** Self-contained isolated-world reader. Never reads field values or page text. */
181
+ function collectLoginSignals() {
182
+ const inputs = Array.from(document.querySelectorAll('input[type="password"]'))
183
+ .filter((input) => !input.disabled && input.getClientRects().length > 0).slice(0, 32);
184
+ const forms = [...new Set(inputs.map((input) => input.form).filter((form) => !!form))];
185
+ return {
186
+ url: location.href, title: document.title.slice(0, 300), passwords: inputs.length,
187
+ forms: forms.map((form) => ({ action: form.action, method: form.getAttribute('method')?.toLowerCase() ?? '' })),
188
+ };
189
+ }
190
+ //# sourceMappingURL=site-risk.js.map
@@ -1,10 +1,16 @@
1
+ import { type KeyObject } from 'node:crypto';
1
2
  import { type ClientProof } from '../../shared/client-auth';
2
3
  import type { CommandFrame } from '../../shared/protocol';
3
- /** Private keys stay in this MCP session's memory. They never enter pairing files or the hub. */
4
- export declare function createClientIdentity(name: () => string): {
4
+ import type { Workspace } from './datadir';
5
+ export interface ClientIdentity {
5
6
  id: string;
6
- sign(cmd: Pick<CommandFrame, "v" | "method" | "params" | "tabId" | "timeoutMs">, challenge: string): ClientProof;
7
- };
8
- export type ClientIdentity = ReturnType<typeof createClientIdentity>;
7
+ publicKey: string;
8
+ profile?: string;
9
+ workspace?: Workspace;
10
+ assertAccess?: () => void;
11
+ sign(cmd: Pick<CommandFrame, 'v' | 'method' | 'params' | 'tabId' | 'timeoutMs'>, challenge: string): ClientProof;
12
+ }
13
+ /** Ordinary app keys are ephemeral; paired phones supply their privately stored key. */
14
+ export declare function createClientIdentity(name: () => string, savedKey?: KeyObject): ClientIdentity;
9
15
  export declare const withClientIdentity: <T>(identity: ClientIdentity, fn: () => T) => T;
10
16
  export declare const currentClientIdentity: () => ClientIdentity | undefined;
@@ -5,13 +5,16 @@ exports.createClientIdentity = createClientIdentity;
5
5
  const node_async_hooks_1 = require("node:async_hooks");
6
6
  const node_crypto_1 = require("node:crypto");
7
7
  const client_auth_1 = require("../../shared/client-auth");
8
- /** Private keys stay in this MCP session's memory. They never enter pairing files or the hub. */
9
- function createClientIdentity(name) {
10
- const { privateKey, publicKey } = (0, node_crypto_1.generateKeyPairSync)('ec', { namedCurve: 'prime256v1' });
8
+ const workspace_1 = require("./workspace");
9
+ /** Ordinary app keys are ephemeral; paired phones supply their privately stored key. */
10
+ function createClientIdentity(name, savedKey) {
11
+ const privateKey = savedKey ?? (0, node_crypto_1.generateKeyPairSync)('ec', { namedCurve: 'prime256v1' }).privateKey;
12
+ const publicKey = (0, node_crypto_1.createPublicKey)(privateKey);
11
13
  const der = publicKey.export({ type: 'spki', format: 'der' });
12
14
  const encoded = der.toString('base64');
13
15
  return {
14
16
  id: (0, node_crypto_1.createHash)('sha256').update(der).digest('hex'),
17
+ publicKey: encoded,
15
18
  sign(cmd, challenge) {
16
19
  const proof = { publicKey: encoded, name: (0, client_auth_1.clientName)(name()), challenge, issuedAt: Date.now(), nonce: (0, node_crypto_1.randomUUID)() };
17
20
  return { ...proof, signature: (0, node_crypto_1.sign)('sha256', Buffer.from((0, client_auth_1.clientSigningPayload)(cmd, proof)), {
@@ -21,7 +24,7 @@ function createClientIdentity(name) {
21
24
  };
22
25
  }
23
26
  const current = new node_async_hooks_1.AsyncLocalStorage();
24
- const withClientIdentity = (identity, fn) => current.run(identity, fn);
27
+ const withClientIdentity = (identity, fn) => current.run(identity, () => identity.workspace ? (0, workspace_1.withWorkspace)(identity.workspace, fn) : fn());
25
28
  exports.withClientIdentity = withClientIdentity;
26
29
  const currentClientIdentity = () => current.getStore();
27
30
  exports.currentClientIdentity = currentClientIdentity;
@@ -14,6 +14,7 @@ function defaultTimeoutFor(method) {
14
14
  * near-superset; unknown codes degrade to CDP_ERROR while keeping the message). */
15
15
  function mapWireErrorCode(code) {
16
16
  const known = {
17
+ RESULT_TOO_LARGE: 'RESULT_TOO_LARGE',
17
18
  TIMEOUT: 'TIMEOUT',
18
19
  POLICY_DENIED: 'POLICY_DENIED',
19
20
  DETACHED: 'DETACHED',
@@ -14,6 +14,7 @@ export type PairState = 'ok' | 'profile_mismatch' | 'token_mismatch' | 'version_
14
14
  export type ProfileNaming = 'options' | 'auto' | 'legacy';
15
15
  /** One paired browser, as reported by chrome_status. */
16
16
  export interface PairedProfile {
17
+ siteChecks?: boolean;
17
18
  /** Public, extension-generated replay scope; not a credential. */
18
19
  approvalChallenge?: string;
19
20
  name: string;