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.
- package/README.md +77 -14
- package/SETUP.md +40 -7
- package/dist/shared/activity.d.ts +25 -0
- package/dist/shared/activity.js +72 -0
- package/dist/shared/browser-permissions.d.ts +20 -0
- package/dist/shared/browser-permissions.js +40 -0
- package/dist/shared/client-auth.d.ts +28 -0
- package/dist/shared/client-auth.js +38 -0
- package/dist/shared/observers.d.ts +1 -1
- package/dist/shared/observers.js +1 -3
- package/dist/shared/policy.d.ts +5 -7
- package/dist/shared/policy.js +11 -26
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/shared/protocol.js +3 -3
- package/dist/shared/screenshot.d.ts +4 -2
- package/dist/shared/screenshot.js +13 -8
- package/dist/src/access-browser.d.ts +15 -0
- package/dist/src/access-browser.js +202 -0
- package/dist/src/access.d.ts +2 -0
- package/dist/src/access.js +92 -18
- package/dist/src/bridge/client-identity.d.ts +10 -0
- package/dist/src/bridge/client-identity.js +28 -0
- package/dist/src/bridge/connection.d.ts +5 -0
- package/dist/src/bridge/connection.js +4 -12
- package/dist/src/bridge/peer.d.ts +4 -19
- package/dist/src/bridge/peer.js +17 -8
- package/dist/src/bridge/server.d.ts +7 -16
- package/dist/src/bridge/server.js +64 -17
- package/dist/src/bridge/site-grants.d.ts +1 -2
- package/dist/src/bridge/site-grants.js +4 -3
- package/dist/src/cli.js +1 -3
- package/dist/src/config.js +16 -5
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +69 -25
- package/dist/src/mcp/server.js +5 -3
- package/dist/src/mcp/tools.d.ts +0 -12
- package/dist/src/mcp/tools.js +48 -22
- package/dist/src/remote/key.js +12 -2
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +3 -1
- package/docs/BLUEPRINT.md +6 -1
- package/docs/CLIENT-APPROVAL.md +150 -0
- package/extension-dist/activity-dashboard.css +43 -0
- package/extension-dist/background.js +1036 -73
- package/extension-dist/manifest.json +10 -9
- package/extension-dist/options.html +101 -32
- package/extension-dist/options.js +558 -70
- package/extension-dist/popup.html +164 -90
- package/extension-dist/popup.js +576 -79
- package/package.json +3 -2
package/dist/src/mcp/tools.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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: ['
|
|
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
|
-
|
|
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
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
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();
|
package/dist/src/remote/key.js
CHANGED
|
@@ -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
|
-
(
|
|
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 ??
|
|
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.
|
|
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
|
+
}
|