mcp-browser-extension 0.9.30 → 0.9.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +77 -14
  2. package/SETUP.md +40 -7
  3. package/dist/shared/activity.d.ts +25 -0
  4. package/dist/shared/activity.js +72 -0
  5. package/dist/shared/browser-permissions.d.ts +20 -0
  6. package/dist/shared/browser-permissions.js +40 -0
  7. package/dist/shared/client-auth.d.ts +28 -0
  8. package/dist/shared/client-auth.js +38 -0
  9. package/dist/shared/observers.d.ts +1 -1
  10. package/dist/shared/observers.js +1 -3
  11. package/dist/shared/policy.d.ts +5 -7
  12. package/dist/shared/policy.js +11 -26
  13. package/dist/shared/protocol.d.ts +4 -0
  14. package/dist/shared/protocol.js +3 -3
  15. package/dist/shared/screenshot.d.ts +4 -2
  16. package/dist/shared/screenshot.js +13 -8
  17. package/dist/src/access-browser.d.ts +15 -0
  18. package/dist/src/access-browser.js +202 -0
  19. package/dist/src/access.d.ts +2 -0
  20. package/dist/src/access.js +92 -18
  21. package/dist/src/bridge/client-identity.d.ts +10 -0
  22. package/dist/src/bridge/client-identity.js +28 -0
  23. package/dist/src/bridge/connection.d.ts +5 -0
  24. package/dist/src/bridge/connection.js +4 -12
  25. package/dist/src/bridge/peer.d.ts +4 -19
  26. package/dist/src/bridge/peer.js +17 -8
  27. package/dist/src/bridge/server.d.ts +7 -16
  28. package/dist/src/bridge/server.js +64 -17
  29. package/dist/src/bridge/site-grants.d.ts +1 -2
  30. package/dist/src/bridge/site-grants.js +4 -3
  31. package/dist/src/cli.js +1 -3
  32. package/dist/src/config.js +16 -5
  33. package/dist/src/executor/cdp-executor.d.ts +3 -0
  34. package/dist/src/executor/cdp-executor.js +69 -25
  35. package/dist/src/mcp/server.js +5 -3
  36. package/dist/src/mcp/tools.d.ts +0 -12
  37. package/dist/src/mcp/tools.js +48 -22
  38. package/dist/src/remote/key.js +12 -2
  39. package/dist/src/security/policy.d.ts +4 -0
  40. package/dist/src/security/policy.js +3 -1
  41. package/docs/BLUEPRINT.md +6 -1
  42. package/docs/CLIENT-APPROVAL.md +150 -0
  43. package/extension-dist/activity-dashboard.css +43 -0
  44. package/extension-dist/background.js +1036 -73
  45. package/extension-dist/manifest.json +10 -9
  46. package/extension-dist/options.html +101 -32
  47. package/extension-dist/options.js +558 -70
  48. package/extension-dist/popup.html +164 -90
  49. package/extension-dist/popup.js +576 -79
  50. package/package.json +3 -2
@@ -1,16 +1,4 @@
1
1
  "use strict";
2
- /**
3
- * src/mcp/tools.ts — the MCP tool surface: the advertised catalog
4
- * (`TOOL_DEFINITIONS`, each with a zod `inputSchema`), the name→handler dispatch
5
- * (`TOOL_HANDLERS`), the never-throw firewall (`dispatchToolCall`), and
6
- * `registerTools()` which registers every tool on an `McpServer` via
7
- * `registerTool` — the SDK validates the zod schema before dispatch runs.
8
- *
9
- * Each handler: validate args → **policy-gate against the relevant URL** → call
10
- * the active Executor (or a server-side helper) → serialize via an envelope.
11
- * Nothing here throws to the transport: `dispatchToolCall` renders any thrown
12
- * `Error` as an `isError` result.
13
- */
14
2
  Object.defineProperty(exports, "__esModule", { value: true });
15
3
  exports.TOOL_HANDLERS = exports.TOOL_NAMES = exports.TOOL_DEFINITIONS = void 0;
16
4
  exports.setToolAllowlist = setToolAllowlist;
@@ -24,6 +12,19 @@ exports.resetRateLimiter = resetRateLimiter;
24
12
  exports.dispatchToolCall = dispatchToolCall;
25
13
  exports.assertNoDrift = assertNoDrift;
26
14
  exports.registerTools = registerTools;
15
+ const client_identity_1 = require("../bridge/client-identity");
16
+ /**
17
+ * src/mcp/tools.ts — the MCP tool surface: the advertised catalog
18
+ * (`TOOL_DEFINITIONS`, each with a zod `inputSchema`), the name→handler dispatch
19
+ * (`TOOL_HANDLERS`), the never-throw firewall (`dispatchToolCall`), and
20
+ * `registerTools()` which registers every tool on an `McpServer` via
21
+ * `registerTool` — the SDK validates the zod schema before dispatch runs.
22
+ *
23
+ * Each handler: validate args → **policy-gate against the relevant URL** → call
24
+ * the active Executor (or a server-side helper) → serialize via an envelope.
25
+ * Nothing here throws to the transport: `dispatchToolCall` renders any thrown
26
+ * `Error` as an `isError` result.
27
+ */
27
28
  const node_fs_1 = require("node:fs");
28
29
  const node_path_1 = require("node:path");
29
30
  const zod_1 = require("zod");
@@ -124,7 +125,7 @@ exports.TOOL_DEFINITIONS = [
124
125
  { name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
125
126
  { name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
126
127
  { name: 'page_watch', description: 'Save a local page checkpoint and compare it later. Actions: create (name), compare (watchId), list, delete. Stores accessibility labels and state, never form values. Site allowlist still applies.', inputSchema: { action: zod_1.z.enum(['create', 'compare', 'list', 'delete']), name: zod_1.z.string().optional(), watchId: zod_1.z.string().optional(), tabId: tabIdField } },
127
- { name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
128
+ { name: 'get_cookies', description: "Cookies for the tab's URL (or url); values need --enable-cookie-values.", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
128
129
  { name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
129
130
  { name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), ...FRAME_PROPS, tabId: tabIdField } },
130
131
  { name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField, failOnAuthWall: authWallField } },
@@ -327,13 +328,13 @@ const TOOL_GATE = {
327
328
  *
328
329
  * Runs the REAL `evaluatePolicy` — the same function the gate and the extension
329
330
  * router run, so this can never disagree with them — but with the domain
330
- * allowlist widened to `*`. Only the capability gates (eval / downloads /
331
+ * allowlist set to the single probe host. Only the capability gates (eval / downloads /
331
332
  * uploads / observers / mutations) can then reject, which is exactly the
332
333
  * question we want: a tool blocked merely because the current tab is off the
333
334
  * allowlist stays advertised, since another tab may well be on it.
334
335
  */
335
336
  function isCapabilityDenied(method, policy) {
336
- return !(0, policy_2.evaluatePolicy)('https://probe.invalid/', method, { ...policy, allowDomains: ['*'] }).ok;
337
+ return !(0, policy_2.evaluatePolicy)('https://probe.invalid/', method, { ...policy, allowDomains: ['probe.invalid'] }).ok;
337
338
  }
338
339
  /**
339
340
  * Is `name` usable at all under `policy`? Undefined policy = advertise
@@ -400,6 +401,8 @@ function enabledToolNames() {
400
401
  const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
401
402
  /** How long the gate waits for a just-opened background tab to report its URL. */
402
403
  const OPENING_TAB_WAIT_MS = 5_000;
404
+ /** What `get_cookies` returns in place of a value unless --enable-cookie-values is set. */
405
+ const COOKIE_VALUE_HIDDEN = '[hidden]';
403
406
  /**
404
407
  * Resolve the URL the policy should be evaluated against: the URL of the tab
405
408
  * this very call will act on — the explicit `tabId` when the caller gave one,
@@ -965,7 +968,16 @@ exports.TOOL_HANDLERS = {
965
968
  const url = (0, validators_1.optionalString)(a, 'url');
966
969
  if (url !== undefined)
967
970
  await gate(ctx, 'get_text', { url });
968
- return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url }));
971
+ const result = await ctx.ex.getCookies({ tabId: tabId(a), url });
972
+ if (ctx.policy.allowCookieValues)
973
+ return (0, envelopes_1.jsonResult)(result);
974
+ // A session cookie is the login itself: whoever holds its value can use the
975
+ // account from anywhere. The browser stays signed in without the agent ever
976
+ // seeing it, so hide every value unless the operator opted in.
977
+ return (0, envelopes_1.jsonResult)({
978
+ cookies: result.cookies.map((c) => ({ ...c, value: COOKIE_VALUE_HIDDEN })),
979
+ valuesHidden: 'Cookie values are hidden. Ask the user to restart the server with --enable-cookie-values if they need them.',
980
+ });
969
981
  },
970
982
  storage: async (a, ctx) => {
971
983
  const op = (0, validators_1.requireString)(a, 'op');
@@ -1075,9 +1087,9 @@ exports.TOOL_HANDLERS = {
1075
1087
  const target = (0, validators_1.optionalTarget)(a);
1076
1088
  if (!url && !target)
1077
1089
  throw new validators_1.McpToolError('provide "url" or a target (selector|ref)');
1078
- // By URL, the bytes come from that URL with the person's cookies: gate the
1079
- // URL, not whichever tab the agent is on. By element, gate the tab it is in
1080
- // (the extension then gates where the link leads).
1090
+ // By URL, gate the destination rather than whichever tab the agent is on.
1091
+ // For links, the extension/CDP executor also gates the resolved href before
1092
+ // fetching it; downloads omit browser cookies and refuse redirects.
1081
1093
  await gate(ctx, 'download_file', url ? { url } : { tabId: tabId(a) });
1082
1094
  return (0, envelopes_1.jsonResult)(await ctx.ex.download({ url, target, tabId: tabId(a), suggestedName: (0, validators_1.optionalString)(a, 'suggestedName') }));
1083
1095
  },
@@ -1215,7 +1227,7 @@ exports.TOOL_HANDLERS = {
1215
1227
  });
1216
1228
  },
1217
1229
  chrome_status: async (_a, ctx) => {
1218
- const profiles = profileBridge ? { profiles: profileBridge.pairedProfiles() } : {};
1230
+ const profiles = profileBridge ? { profiles: profileBridge.pairedProfiles().map(({ approvalChallenge: _challenge, activeUrl: _url, ...profile }) => profile) } : {};
1219
1231
  const capabilities = capabilityStatus(ctx.policy);
1220
1232
  // Why nothing is paired, and the steps to fix it, so the agent can relay them
1221
1233
  // on the FIRST call instead of after a run of NO_BACKEND failures.
@@ -1501,6 +1513,10 @@ function modelFromMeta(meta) {
1501
1513
  /** Register the tool surface; returns the names actually advertised. */
1502
1514
  function registerTools(server, policy, accessGuard = access_1.requireAccess) {
1503
1515
  assertNoDrift();
1516
+ const identity = (0, client_identity_1.createClientIdentity)(() => {
1517
+ const client = server.server.getClientVersion();
1518
+ return client ? `${client.name} ${client.version}` : 'Unnamed app';
1519
+ });
1504
1520
  // Register each tool with its zod `inputSchema`. The SDK advertises it in
1505
1521
  // `tools/list` and validates arguments before invoking the handler, which
1506
1522
  // just routes back through `dispatchToolCall` — our never-throw firewall that
@@ -1516,7 +1532,7 @@ function registerTools(server, policy, accessGuard = access_1.requireAccess) {
1516
1532
  policyDropped.push(d.name);
1517
1533
  continue;
1518
1534
  }
1519
- server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args, extra) => {
1535
+ server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args, extra) => (0, client_identity_1.withClientIdentity)(identity, async () => {
1520
1536
  (0, telemetry_1.noteMcpModel)(modelFromMeta(extra?._meta));
1521
1537
  let verifiedNow = false;
1522
1538
  const accessError = await accessGuard(server.server, (0, config_1.resolveDataDir)(), {
@@ -1528,11 +1544,21 @@ function registerTools(server, policy, accessGuard = access_1.requireAccess) {
1528
1544
  });
1529
1545
  if (accessError)
1530
1546
  return accessError;
1547
+ if (profileBridge?.hasActiveExtension()) {
1548
+ const active = (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
1549
+ const profile = profileBridge.hasConnection(active) ? active : profileBridge.pairedProfiles()[0]?.name;
1550
+ try {
1551
+ await profileBridge.sendCommand('ping_probe', { requireClientApproval: true }, { profile });
1552
+ }
1553
+ catch (err) {
1554
+ return (0, envelopes_1.errorResult)(err instanceof Error ? err.message : 'Chrome client approval is required.');
1555
+ }
1556
+ }
1531
1557
  const result = await dispatchToolCall(d.name, args);
1532
1558
  if (verifiedNow)
1533
1559
  result.content.unshift({ type: 'text', text: 'Email verified. MCP Browser Extension access is active.' });
1534
1560
  return result;
1535
- });
1561
+ }));
1536
1562
  registered.push(d.name);
1537
1563
  }
1538
1564
  const advertised = enabledToolNames();
@@ -16,6 +16,14 @@ const node_fs_1 = require("node:fs");
16
16
  const node_path_1 = require("node:path");
17
17
  const KEY_FILE = 'remote-key';
18
18
  const KEY_SHAPE = /^[A-Za-z0-9_-]{43}$/;
19
+ function enforcePrivateMode(path) {
20
+ if (process.platform === 'win32')
21
+ return;
22
+ (0, node_fs_1.chmodSync)(path, 0o600);
23
+ if (((0, node_fs_1.statSync)(path).mode & 0o077) !== 0) {
24
+ throw new Error(`remote key ${path} is group/other-accessible; refusing to expose the key`);
25
+ }
26
+ }
19
27
  function remoteKeyPath(dataDir) {
20
28
  return (0, node_path_1.join)(dataDir, KEY_FILE);
21
29
  }
@@ -24,12 +32,14 @@ function resolveRemoteKey(dataDir, opts = {}) {
24
32
  const path = remoteKeyPath(dataDir);
25
33
  if (!opts.rotate && (0, node_fs_1.existsSync)(path)) {
26
34
  const stored = (0, node_fs_1.readFileSync)(path, 'utf8').trim();
27
- if (KEY_SHAPE.test(stored))
35
+ if (KEY_SHAPE.test(stored)) {
36
+ enforcePrivateMode(path);
28
37
  return stored;
38
+ }
29
39
  }
30
40
  const key = (0, node_crypto_1.randomBytes)(32).toString('base64url');
31
41
  (0, node_fs_1.writeFileSync)(path, `${key}\n`, { mode: 0o600 });
32
- (0, node_fs_1.chmodSync)(path, 0o600); // a file that already existed keeps its old mode otherwise
42
+ enforcePrivateMode(path);
33
43
  return key;
34
44
  }
35
45
  /** Constant-time comparison, so response timing reveals nothing about the key. */
@@ -36,6 +36,10 @@ export interface Policy extends WirePolicy {
36
36
  * `AUTH_REQUIRED` when the page it lands on is a high-confidence sign-in
37
37
  * wall, so an expired session is never scored as some other failure. */
38
38
  failOnAuthWall?: boolean;
39
+ /** `--enable-cookie-values`: `get_cookies` returns each cookie's value. Off by
40
+ * default, because a session cookie is the login itself: whoever holds it can
41
+ * use the account from anywhere, with no password and no browser. */
42
+ allowCookieValues?: boolean;
39
43
  }
40
44
  /** The SAFE default: deny everything until the user opts in. */
41
45
  export declare const DEFAULT_POLICY: Readonly<Policy>;
@@ -45,11 +45,12 @@ exports.DEFAULT_POLICY = Object.freeze({
45
45
  redact: false,
46
46
  redactPatterns: [],
47
47
  failOnAuthWall: false,
48
+ allowCookieValues: false,
48
49
  });
49
50
  /** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
50
51
  function resolvePolicy(partial) {
51
52
  return {
52
- allowDomains: partial?.allowDomains ?? [...exports.DEFAULT_POLICY.allowDomains],
53
+ allowDomains: (partial?.allowDomains ?? exports.DEFAULT_POLICY.allowDomains).map(policy_1.normalizeDomainPattern).filter(Boolean),
53
54
  allowEval: partial?.allowEval ?? exports.DEFAULT_POLICY.allowEval,
54
55
  allowDownloads: partial?.allowDownloads ?? exports.DEFAULT_POLICY.allowDownloads,
55
56
  allowUploads: partial?.allowUploads ?? exports.DEFAULT_POLICY.allowUploads,
@@ -60,6 +61,7 @@ function resolvePolicy(partial) {
60
61
  redact: partial?.redact ?? exports.DEFAULT_POLICY.redact,
61
62
  redactPatterns: partial?.redactPatterns ?? [...(exports.DEFAULT_POLICY.redactPatterns ?? [])],
62
63
  failOnAuthWall: partial?.failOnAuthWall ?? exports.DEFAULT_POLICY.failOnAuthWall,
64
+ allowCookieValues: partial?.allowCookieValues ?? exports.DEFAULT_POLICY.allowCookieValues,
63
65
  };
64
66
  }
65
67
  /**
package/docs/BLUEPRINT.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Chrome MCP — Build-Ready Blueprint (v1)
2
2
 
3
+ Current client authorization: see [Client approval](CLIENT-APPROVAL.md). The
4
+ extension now requires a separate signed, human-approved session key in addition
5
+ to the transport token. This supersedes descriptions below that treat the
6
+ transport token alone as sufficient authorization.
7
+
3
8
  > An MCP server that lets Claude drive a real Chrome browser. One pluggable
4
9
  > `Executor` interface, two backends: an MV3 **extension** (primary, drives the
5
10
  > user's real Chrome via `chrome.debugger`/CDP) and a **CDP fallback** (the
@@ -554,7 +559,7 @@ threat** (this tool feeds untrusted page text to an LLM that can call `eval`).
554
559
  3. **Origin is NOT a security layer.** Documented as defense-in-depth against *browser-page* attackers only (a web page cannot forge a `chrome-extension://` Origin; a native process can). The token holds independently. We never relax token strength on Origin's account. Never reject a valid-token dev connection solely on Origin mismatch. [RESOLVED — blocker 2, mv3 major Origin]
555
560
  4. **No `/connect` HTTP endpoint.** Token bootstrap is the **native-messaging trampoline** reading the 0600 file (the model/attacker can't read it), with a manual file-path-pairing fallback. No race-able network token vendor exists. [RESOLVED — blocker 3]
556
561
  5. **Token never logged.** Never on stdout or stderr; only in the 0600 file (mode verified post-write, fail-closed). Any human-readable pairing artifact is also 0600 and short-lived. `policy.test.ts` asserts the token appears on neither stream. `logErr` redacts. [RESOLVED — major 5]
557
- 6. **Default-deny domain policy, ON by default, gating READS too.** `Policy { allowDomains: glob[]; allowEval; allowDownloads; allowAllTabs }`. Absent config → **SAFE DEFAULT**: navigate only to `about:blank` + already-open allowlisted tabs, **eval denied cross-domain, reads (`get_text`/`get_html`/`screenshot`/`eval`) denied outside the allowlist**, downloads off. The shared `evaluatePolicy(url, method, policy)` runs at **both ends** — wrapped server-side as `assertUrlAllowed` in the executor dispatch (both backends), and re-run by the extension router against the policy delivered in `welcome` — before any attach/navigate/eval/read. `--unsafe-all-domains` (= `allowDomains:['*']`) is the loud-logged escape hatch. [RESOLVED — major 4]
562
+ 6. **Default-deny domain policy, ON by default, gating READS too.** `Policy { allowDomains: glob[]; allowEval; allowDownloads; allowAllTabs }`. Absent config → **SAFE DEFAULT**: navigate only to `about:blank` + already-open allowlisted tabs, **eval denied cross-domain, reads (`get_text`/`get_html`/`screenshot`/`eval`) denied outside the allowlist**, downloads off. The shared `evaluatePolicy(url, method, policy)` runs at **both ends** — wrapped server-side as `assertUrlAllowed` in the executor dispatch (both backends), and re-run by the extension router against the policy delivered in `welcome` — before any attach/navigate/eval/read. All-site access is unsupported; catch-all entries never grant access and named websites require approval. [RESOLVED — major 4]
558
563
  7. **Safe-mode shipped in v1 (not "future").** Default disables `eval` and the entire mutating tool set; `--enable-mutations` / `--unsafe-enable-eval` opt in. `eval`'s effective target origin (the tab's current URL) is allowlist-checked before dispatch. [RESOLVED — major eval]
559
564
  8. **Narrowed manifest:** no `<all_urls>`; `host_permissions:[]` + `optional_host_permissions` requested on demand after an explicit user action-click grant before first attach. [RESOLVED — major 4]
560
565
  9. **Displacement is a security event:** superseding connects are logged loudly + surfaced in `chrome_status`; the active connection is pinned to the first `hello` ext id and won't be displaced by a different id without re-pair. [RESOLVED — minor 9]
@@ -0,0 +1,150 @@
1
+ # Client approval
2
+
3
+ The extension requires a person to approve each new MCP app session in its Chrome
4
+ popup or Settings before it can read or act on browser pages. This is independent of
5
+ email verification, transport pairing, domain restrictions and client permission
6
+ modes. No command-line flag disables this extension gate.
7
+
8
+ ## Using it
9
+
10
+ 1. Start the app's MCP connection and run `chrome_status`.
11
+ 2. Click the extension's toolbar icon to open **App connections** in the popup.
12
+ The same controls are available in **Settings**.
13
+ 3. Compare the key fingerprint with the tool result. The app name is supplied by
14
+ the requesting app and is not proof of its identity.
15
+ 4. Click **Approve for 8 hours**, then retry the tool.
16
+
17
+ A request expires after ten minutes. Approval lasts eight hours in that Chrome
18
+ profile. Expired access requires a new approval. Each initialized MCP session,
19
+ including each remote HTTP session, receives a new key. Restarting a session
20
+ requires approval again. Several sessions with the same name are separate keys.
21
+
22
+ App approval does not approve websites. Each website must also be on the allowed
23
+ sites list. A blocked request gives one line of instructions to click Allow next
24
+ to that website in the Chrome popup. All-site access is no longer supported:
25
+ remove `--unsafe-all-domains` from older configurations. Catch-all policy entries
26
+ grant no access, including when sent by an older server.
27
+
28
+ Allowing a website permits approved apps to read and take actions there, subject
29
+ to configured tool permissions. This includes sending messages, deleting
30
+ information and making purchases. The extension does not ask again for each
31
+ action. Website grants are remembered until removed and apply to all approved
32
+ app sessions; app access still expires after eight hours.
33
+
34
+ These controls restrict which apps and websites are accessible. They do not
35
+ prevent an approved app from making a mistake or following malicious page
36
+ instructions on an allowed website. Removing access blocks new commands; it
37
+ cannot undo completed actions.
38
+
39
+ Use **Remove access** to block subsequent commands from one key. An action already
40
+ running may finish. **Approve again for 8 hours** explicitly restores that key. If
41
+ either view reports a storage error, the decision was not saved: resolve the error
42
+ and retry before relying on it across an extension restart.
43
+
44
+ ## Activity and stopping access
45
+
46
+ The popup shows the latest five activity rows received by the extension. Settings
47
+ shows history from up to 100 requests, filtered by Running, Done, Blocked, Failed
48
+ or Interrupted. Successful opening and closing of the same tab by the same app
49
+ share one row; expand it to see the app, key and times. Browser-session tab IDs
50
+ are stored only to match those actions. Blank tabs display their blank-page address.
51
+ Entries contain a timestamp, command name, website hostname, app-supplied name,
52
+ verified signing-key fingerprint and outcome. Invalid or unsigned identities
53
+ are shown as Unverified app. Health and approval probes are omitted.
54
+
55
+ This history stays in trusted Chrome session storage and is cleared when Chrome
56
+ closes or the extension reloads. Clear history removes it from both views;
57
+ results from requests already running when cleared do not recreate their entries.
58
+ No request arguments, page contents, screenshots, cookies, typed values, full URLs
59
+ or raw error messages are recorded. Requests refused before reaching Chrome may
60
+ be absent. It is a convenience history, not a complete or tamper-proof audit.
61
+ Interrupted means that a result was withheld or lost; an action may still have
62
+ finished. A successful command does not establish that a website accepted its
63
+ business operation.
64
+
65
+ **Stop access** immediately invalidates pending command checks, closes the
66
+ extension's bridge connection and saves a durable stop. It revokes all app keys,
67
+ clears activity borders and unregisters observer scripts for future pages.
68
+ Already-injected hooks and commands already executing may finish; Stop cannot
69
+ undo completed actions. Results from old requests are not delivered after Stop
70
+ or to a replacement connection.
71
+
72
+ The stop survives worker eviction, Chrome/extension restarts, keepalive alarms,
73
+ pairing changes and ordinary Reconnect requests. **Resume connection** is a
74
+ separate trusted click in the popup or Settings. It reconnects using the saved
75
+ pairing; each app must then be approved again. Website permissions and proxy
76
+ settings are preserved. Stop applies to this Chrome profile, not other profiles
77
+ or the local MCP server process.
78
+
79
+ Failed stop persistence blocks access in the current worker and reports an error;
80
+ retry before relying on the setting across restarts. No CLI flag or ordinary
81
+ page message can resume access or erase this history.
82
+
83
+ ## Enforcement
84
+
85
+ Each session creates a P-256 key pair in memory. Only the public key travels to
86
+ the bridge and extension. Chrome saves the public key, its SHA-256 fingerprint,
87
+ the supplied app name, decision and expiration. Private keys never enter pairing
88
+ files, extension storage, logs or tool results.
89
+
90
+ The session signs the method, JSON parameters, target tab, timeout, protocol
91
+ version, app identity, timestamp, random nonce and a challenge generated by the
92
+ extension for that WebSocket connection. Chrome verifies the signature and grant
93
+ before resolving the target tab, then rechecks the grant before execution.
94
+ Commands older than two minutes, invalid signatures and replayed nonces are
95
+ refused. Reconnecting changes the challenge, including after a worker restart.
96
+
97
+ A hub forwards a peer's original signed request. It never lends its own key to
98
+ an incoming peer. The shared bridge token remains a transport credential; knowing
99
+ it does not provide an approved signing key. Profile renames also require a
100
+ signed approval probe for the exact rename. Peer status no longer broadcasts
101
+ cached page URLs.
102
+
103
+ The only unsigned command is an empty health probe. It returns a constant response
104
+ without looking up a tab or exposing a URL. Client approval requests carry valid
105
+ signatures; pending requests and replay tracking are bounded. An unavailable
106
+ approval store blocks access. Grant writes must succeed before access is enabled.
107
+
108
+ Only the exact top-level extension Settings or popup page can send decisions.
109
+ Approval buttons ignore synthetic clicks. Browser commands cannot read or drive
110
+ the extension's own approval pages. Extension local storage is restricted to
111
+ trusted extension contexts.
112
+
113
+ ## Compatibility and limits
114
+
115
+ Update the MCP server and extension together. The additive `client-approval-v1`
116
+ capability signals support. Updated servers refuse browser commands to older
117
+ extensions; updated extensions refuse unsigned commands from older servers.
118
+ Transport pairing may succeed while browser access remains blocked.
119
+
120
+ This is session authorization for the extension, not isolation between approved
121
+ apps. Approved apps still share permitted tabs and the existing browser policy.
122
+ Website content access additionally requires a named Chrome host permission.
123
+ The popup's Allow action requests Chrome permission before adding the server
124
+ grant, and Remove revokes the host permission. Server flags, saved app approval
125
+ and YOLO mode cannot supply the Chrome grant. Cookies and downloads also require
126
+ their optional API permission. Debugger and proxy remain required permissions
127
+ because Chrome does not support them as optional; debugger access is checked
128
+ against website permission by the extension, rather than isolated by Chrome.
129
+ The published CLI is extension-only; development CDP executors outside this
130
+ extension do not inherit its approval gate.
131
+
132
+ It cannot protect against someone who controls the operating system, modifies
133
+ the installed extension or server, steals a live approved private key or an
134
+ approved remote session's credentials, or persuades the
135
+ person to approve the attacker's key. A stolen approved key remains useful until
136
+ revoked or expired. No independent security audit has been completed for this
137
+ change, and it is not a claim that the extension is unhackable.
138
+
139
+ ## Verification
140
+
141
+ `test/client-approval.test.ts` exercises signatures, tampering, replay,
142
+ independent keys, expiry, revocation, storage errors, sender restrictions and
143
+ concurrent session identity propagation. `test/e2e/client-approval.test.ts` uses
144
+ the real extension in isolated Chromium to verify trusted approval clicks,
145
+ self-approval rejection, popup and Settings synchronization, revocation, shared-token attacks and separate MCP
146
+ sessions when email verification is bypassed. The existing browser suites also
147
+ exercise commands after explicitly approving their test client in Chrome.
148
+
149
+ For an isolated build that does not overwrite a folder loaded in your main
150
+ Chrome profile, set `MBE_EXTENSION_BUILD_DIR` when building and running E2E tests.
@@ -0,0 +1,43 @@
1
+ .access-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
2
+ .access-controls button { margin: 0; min-height: 34px; }
3
+ #access-stop { color: #b91c1c; border: 1px solid #fca5a5; background: transparent; }
4
+ #access-stop:hover { background: #fef2f2; }
5
+ #access-note, #activity-note { font-size: 12px; line-height: 1.5; }
6
+ #access-note[data-error="true"], #activity-note[data-error="true"] { color: #b91c1c; }
7
+ .activity-list { list-style: none; margin: 8px 0; padding: 0; }
8
+ body:not([data-view="popup"]) .activity-list {
9
+ max-height: 24rem;
10
+ overflow-y: auto;
11
+ overscroll-behavior: contain;
12
+ padding-inline-end: 8px;
13
+ scrollbar-color: var(--site-primary, #2563eb) #f3f4f6;
14
+ scrollbar-width: thin;
15
+ }
16
+ body:not([data-view="popup"]) .activity-list:focus-visible { outline: 2px solid var(--site-ring, #2563eb); outline-offset: 2px; }
17
+ .activity-item { border-bottom: 1px solid var(--line, #e6e8ec); overflow-wrap: anywhere; font-size: 12px; }
18
+ .activity-item:last-child { border-bottom: 0; }
19
+ .activity-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 10px 0; cursor: pointer; list-style: none; }
20
+ .activity-row::-webkit-details-marker { display: none; }
21
+ .activity-row::before { content: ''; width: 5px; height: 5px; flex: none; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(-45deg); }
22
+ details[open] > .activity-row::before { transform: rotate(45deg); }
23
+ .activity-host { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
24
+ .activity-label { max-width: 48%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
25
+ .activity-outcome { flex: none; color: var(--muted, #555); font-size: 11px; }
26
+ [data-outcome="blocked"] .activity-outcome, [data-outcome="failed"] .activity-outcome { color: var(--warn, #9a3412); }
27
+ .activity-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 0 0 10px 13px; color: var(--muted, #555); font-size: 11px; }
28
+ .activity-meta span { flex: 1; min-width: 0; }
29
+ .activity-meta .activity-opened { flex-basis: 100%; }
30
+ .activity-meta time { white-space: nowrap; }
31
+ .activity-actions { display: flex; flex-wrap: wrap; gap: 8px; }
32
+ .activity-actions button { margin: 0; }
33
+ .activity-help { margin-top: 10px; font-size: 12px; color: var(--muted, #555); }
34
+ .activity-help summary { cursor: pointer; }
35
+ .activity-help p { margin: 6px 0; }
36
+ #activity-filter { max-width: 100%; }
37
+ #activity-section button:focus-visible, .access-controls button:focus-visible, #activity-filter:focus-visible, .activity-help summary:focus-visible, .activity-row:focus-visible { outline: 2px solid var(--accent, #2563eb); outline-offset: 2px; }
38
+ [hidden] { display: none !important; }
39
+ @media (prefers-color-scheme: dark) {
40
+ body[data-view="popup"] #access-stop { color: #fca5a5; border-color: #7f1d1d; }
41
+ body[data-view="popup"] #access-stop:hover { background: #450a0a; }
42
+ body[data-view="popup"] #access-note[data-error="true"], body[data-view="popup"] #activity-note[data-error="true"] { color: #fca5a5; }
43
+ }