@mehmoodqureshi/chrome-mcp 0.3.0 → 0.4.1
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 +11 -4
- package/dist/shared/protocol.d.ts +2 -2
- package/dist/shared/protocol.js +1 -0
- package/dist/shared/snapshot.js +64 -2
- package/dist/src/bridge/server.js +17 -1
- package/dist/src/cli.js +24 -3
- package/dist/src/config.js +13 -0
- package/dist/src/executor/cdp-executor.d.ts +10 -0
- package/dist/src/executor/cdp-executor.js +230 -129
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +6 -1
- package/dist/src/executor/stub-executor.d.ts +1 -0
- package/dist/src/executor/stub-executor.js +3 -0
- package/dist/src/executor/types.d.ts +22 -1
- package/dist/src/executor/types.js +29 -1
- package/dist/src/mcp/server.d.ts +2 -2
- package/dist/src/mcp/server.js +7 -5
- package/dist/src/mcp/tools.d.ts +2 -0
- package/dist/src/mcp/tools.js +61 -1
- package/dist/src/mcp/validators.d.ts +6 -0
- package/dist/src/mcp/validators.js +20 -2
- package/dist/src/security/policy.d.ts +14 -3
- package/dist/src/security/policy.js +23 -4
- package/docs/BLUEPRINT.md +2 -2
- package/extension-dist/background.js +77 -3
- package/extension-dist/manifest.json +20 -5
- package/package.json +1 -1
package/dist/src/mcp/tools.js
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.TOOL_HANDLERS = exports.TOOL_DEFINITIONS = void 0;
|
|
15
|
+
exports.resetRateLimiter = resetRateLimiter;
|
|
15
16
|
exports.dispatchToolCall = dispatchToolCall;
|
|
16
17
|
exports.assertNoDrift = assertNoDrift;
|
|
17
18
|
exports.registerTools = registerTools;
|
|
19
|
+
const node_path_1 = require("node:path");
|
|
18
20
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
19
21
|
const types_1 = require("../executor/types");
|
|
20
22
|
const manager_1 = require("../executor/manager");
|
|
@@ -59,6 +61,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
59
61
|
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: obj({ selector: { type: 'string' }, tabId: { type: 'string' } }) },
|
|
60
62
|
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: obj({ fields: { type: 'object' }, submitSelector: { type: 'string' }, tabId: { type: 'string' } }, ['fields']) },
|
|
61
63
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: obj({ url: { type: 'string' }, ...TARGET_PROPS, suggestedName: { type: 'string' }, tabId: { type: 'string' } }) },
|
|
64
|
+
{ name: 'upload_file', description: 'Set local file(s) on a file <input> (target by selector or ref) — uploads without the OS dialog. Requires --enable-uploads. `files` are absolute local paths.', inputSchema: obj({ ...TARGET_PROPS, files: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['files']) },
|
|
62
65
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
|
|
63
66
|
];
|
|
64
67
|
/** Resolve the URL the policy should be evaluated against (the active tab). */
|
|
@@ -122,7 +125,7 @@ exports.TOOL_HANDLERS = {
|
|
|
122
125
|
type: async (a, ctx) => {
|
|
123
126
|
const t = (0, validators_1.requireTarget)(a);
|
|
124
127
|
await gate(ctx, 'type');
|
|
125
|
-
return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireString)(a, 'text'), {
|
|
128
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'text'), 'text', validators_1.MAX_TEXT_LEN), {
|
|
126
129
|
tabId: tabId(a),
|
|
127
130
|
clear: (0, validators_1.optionalBoolean)(a, 'clear'),
|
|
128
131
|
pressEnter: (0, validators_1.optionalBoolean)(a, 'pressEnter'),
|
|
@@ -241,6 +244,10 @@ exports.TOOL_HANDLERS = {
|
|
|
241
244
|
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
242
245
|
throw new validators_1.McpToolError('"fields" must be an object mapping selector -> string|boolean');
|
|
243
246
|
}
|
|
247
|
+
for (const [sel, val] of Object.entries(fields)) {
|
|
248
|
+
if (typeof val === 'string')
|
|
249
|
+
(0, validators_1.requireWithinLength)(val, `fields["${sel}"]`, validators_1.MAX_TEXT_LEN);
|
|
250
|
+
}
|
|
244
251
|
return (0, envelopes_1.jsonResult)(await (0, helpers_1.fillForm)(ctx.ex, {
|
|
245
252
|
fields: fields,
|
|
246
253
|
submitSelector: (0, validators_1.optionalString)(a, 'submitSelector'),
|
|
@@ -255,6 +262,28 @@ exports.TOOL_HANDLERS = {
|
|
|
255
262
|
throw new validators_1.McpToolError('provide "url" or a target (selector|ref)');
|
|
256
263
|
return (0, envelopes_1.jsonResult)(await ctx.ex.download({ url, target, tabId: tabId(a), suggestedName: (0, validators_1.optionalString)(a, 'suggestedName') }));
|
|
257
264
|
},
|
|
265
|
+
upload_file: async (a, ctx) => {
|
|
266
|
+
const t = (0, validators_1.requireTarget)(a);
|
|
267
|
+
await gate(ctx, 'upload_file');
|
|
268
|
+
const files = (0, validators_1.optionalStringArray)(a, 'files');
|
|
269
|
+
if (!files || files.length === 0)
|
|
270
|
+
throw new validators_1.McpToolError('"files" must be a non-empty array of absolute local paths');
|
|
271
|
+
// Path restriction: uploads MUST be confined to a configured directory. Without
|
|
272
|
+
// one, any absolute path (e.g. ~/.ssh/id_rsa) could be uploaded to a page, so we
|
|
273
|
+
// refuse rather than allow unrestricted local-file access. With a dir, every file
|
|
274
|
+
// must resolve inside it (blocks `..` traversal and arbitrary-file exfiltration).
|
|
275
|
+
if (!ctx.policy.uploadsDir) {
|
|
276
|
+
throw new validators_1.McpToolError('upload denied: uploads require an --uploads-dir to be configured (refusing unrestricted local-file access)');
|
|
277
|
+
}
|
|
278
|
+
const dir = (0, node_path_1.resolve)(ctx.policy.uploadsDir);
|
|
279
|
+
for (const f of files) {
|
|
280
|
+
const abs = (0, node_path_1.resolve)(f);
|
|
281
|
+
if (abs !== dir && !abs.startsWith(dir + node_path_1.sep)) {
|
|
282
|
+
throw new validators_1.McpToolError(`upload denied: "${f}" is outside the allowed uploads dir (${dir})`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
286
|
+
},
|
|
258
287
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
259
288
|
};
|
|
260
289
|
// ---------------------------------------------------------------------------
|
|
@@ -267,10 +296,41 @@ function errMessage(err) {
|
|
|
267
296
|
return `internal error: ${err.message}`;
|
|
268
297
|
return `internal error: ${String(err)}`;
|
|
269
298
|
}
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Rate limiting — a sliding window over tool calls for the active session.
|
|
301
|
+
// Generous by default so normal use and the test suite are unaffected; tune
|
|
302
|
+
// via the constants below.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
/** Max tool calls permitted within `RATE_WINDOW_MS`. */
|
|
305
|
+
const RATE_MAX_CALLS = 600;
|
|
306
|
+
/** Sliding-window length, in milliseconds. */
|
|
307
|
+
const RATE_WINDOW_MS = 60_000;
|
|
308
|
+
/** Timestamps (ms) of recent dispatches; older entries are evicted lazily. */
|
|
309
|
+
let rateWindow = [];
|
|
310
|
+
/** Reset limiter state — for tests that exercise the ceiling. */
|
|
311
|
+
function resetRateLimiter() {
|
|
312
|
+
rateWindow = [];
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Record one call and report whether it is within the ceiling. Evicts entries
|
|
316
|
+
* older than the window so the array stays bounded.
|
|
317
|
+
*/
|
|
318
|
+
function allowCall(now) {
|
|
319
|
+
const cutoff = now - RATE_WINDOW_MS;
|
|
320
|
+
if (rateWindow.length > 0 && rateWindow[0] <= cutoff) {
|
|
321
|
+
rateWindow = rateWindow.filter((t) => t > cutoff);
|
|
322
|
+
}
|
|
323
|
+
if (rateWindow.length >= RATE_MAX_CALLS)
|
|
324
|
+
return false;
|
|
325
|
+
rateWindow.push(now);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
270
328
|
async function dispatchToolCall(name, rawArgs) {
|
|
271
329
|
const handler = exports.TOOL_HANDLERS[name];
|
|
272
330
|
if (!handler)
|
|
273
331
|
return (0, envelopes_1.errorResult)(`unknown tool: ${name}`);
|
|
332
|
+
if (!allowCall(Date.now()))
|
|
333
|
+
return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
|
|
274
334
|
try {
|
|
275
335
|
const mgr = (0, manager_1.getManager)();
|
|
276
336
|
const ex = await mgr.ensureReady();
|
|
@@ -13,6 +13,12 @@ import type { Target } from '../executor/types';
|
|
|
13
13
|
export declare class McpToolError extends Error {
|
|
14
14
|
constructor(message: string);
|
|
15
15
|
}
|
|
16
|
+
/** Max length for CSS selector strings. Real selectors are tiny; cap generously. */
|
|
17
|
+
export declare const MAX_SELECTOR_LEN = 2000;
|
|
18
|
+
/** Max length for free-text input (type text, fill_form values). ~100KB. */
|
|
19
|
+
export declare const MAX_TEXT_LEN = 100000;
|
|
20
|
+
/** Throw if `value` exceeds `max` chars. `key` names the field for the message. */
|
|
21
|
+
export declare function requireWithinLength(value: string, key: string, max: number): string;
|
|
16
22
|
/** Coerce raw tool args into a plain object, rejecting non-objects. */
|
|
17
23
|
export declare function asArgs(raw: unknown): Record<string, unknown>;
|
|
18
24
|
export declare function requireString(args: Record<string, unknown>, key: string): string;
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* an actionable message (rendered as a structured `isError` result upstream).
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.McpToolError = void 0;
|
|
9
|
+
exports.MAX_TEXT_LEN = exports.MAX_SELECTOR_LEN = exports.McpToolError = void 0;
|
|
10
|
+
exports.requireWithinLength = requireWithinLength;
|
|
10
11
|
exports.asArgs = asArgs;
|
|
11
12
|
exports.requireString = requireString;
|
|
12
13
|
exports.optionalString = optionalString;
|
|
@@ -27,6 +28,20 @@ class McpToolError extends Error {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
exports.McpToolError = McpToolError;
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Input size limits — defend against runaway callers passing huge strings.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** Max length for CSS selector strings. Real selectors are tiny; cap generously. */
|
|
35
|
+
exports.MAX_SELECTOR_LEN = 2_000;
|
|
36
|
+
/** Max length for free-text input (type text, fill_form values). ~100KB. */
|
|
37
|
+
exports.MAX_TEXT_LEN = 100_000;
|
|
38
|
+
/** Throw if `value` exceeds `max` chars. `key` names the field for the message. */
|
|
39
|
+
function requireWithinLength(value, key, max) {
|
|
40
|
+
if (value.length > max) {
|
|
41
|
+
throw new McpToolError(`"${key}" is too long (${value.length} chars; max ${max})`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
30
45
|
/** Coerce raw tool args into a plain object, rejecting non-objects. */
|
|
31
46
|
function asArgs(raw) {
|
|
32
47
|
if (raw === undefined || raw === null)
|
|
@@ -91,7 +106,10 @@ function requireTarget(args) {
|
|
|
91
106
|
if (hasSel === hasRef) {
|
|
92
107
|
throw new McpToolError('provide exactly one of "selector" or "ref"');
|
|
93
108
|
}
|
|
94
|
-
|
|
109
|
+
if (hasSel) {
|
|
110
|
+
return { selector: requireWithinLength(args.selector, 'selector', exports.MAX_SELECTOR_LEN) };
|
|
111
|
+
}
|
|
112
|
+
return { ref: requireWithinLength(args.ref, 'ref', exports.MAX_SELECTOR_LEN) };
|
|
95
113
|
}
|
|
96
114
|
/** Like `requireTarget` but the target is optional (whole-page reads). */
|
|
97
115
|
function optionalTarget(args) {
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
* threat, so the policy:
|
|
7
7
|
* - is ON by default with a SAFE default (empty allowlist, no eval, no
|
|
8
8
|
* downloads, mutations disabled),
|
|
9
|
-
* - gates READS as well as writes (reads are the exfil payload)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* - gates READS as well as writes (reads are the exfil payload).
|
|
10
|
+
*
|
|
11
|
+
* SCOPE OF ENFORCEMENT: this policy is enforced SERVER-SIDE ONLY, at the executor
|
|
12
|
+
* dispatch chokepoint (`assertUrlAllowed` in src/mcp/tools.ts). The extension does
|
|
13
|
+
* NOT independently re-check the policy, so the bridge token is the sole trust
|
|
14
|
+
* boundary for the WebSocket: any client holding the token can issue commands the
|
|
15
|
+
* extension will execute, regardless of this policy. The policy constrains the
|
|
16
|
+
* official MCP server (and thus the LLM driving it); it is not a second line of
|
|
17
|
+
* defense against a rogue token-holding client. (A future hardening would mirror
|
|
18
|
+
* this gate inside the extension router via a policy delivered in the welcome frame.)
|
|
12
19
|
*
|
|
13
20
|
* `assertUrlAllowed(url, method, policy)` is the single chokepoint. It throws an
|
|
14
21
|
* `ExecutorError('POLICY_DENIED', …)` which the never-throw dispatch firewall
|
|
@@ -22,6 +29,10 @@ export interface Policy {
|
|
|
22
29
|
allowEval: boolean;
|
|
23
30
|
/** Allow `download_file`. */
|
|
24
31
|
allowDownloads: boolean;
|
|
32
|
+
/** Allow `upload_file` (sends local files to the page — exfiltration risk). */
|
|
33
|
+
allowUploads: boolean;
|
|
34
|
+
/** If set, `upload_file` may only read files inside this directory (absolute path). */
|
|
35
|
+
uploadsDir?: string;
|
|
25
36
|
/** Allow acting on / reading tabs whose URL is not in `allowDomains` is governed
|
|
26
37
|
* by `allowDomains`; this flag instead relaxes tab *management* (list/select)
|
|
27
38
|
* to all tabs regardless of their URL. Default false. */
|
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
* threat, so the policy:
|
|
8
8
|
* - is ON by default with a SAFE default (empty allowlist, no eval, no
|
|
9
9
|
* downloads, mutations disabled),
|
|
10
|
-
* - gates READS as well as writes (reads are the exfil payload)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - gates READS as well as writes (reads are the exfil payload).
|
|
11
|
+
*
|
|
12
|
+
* SCOPE OF ENFORCEMENT: this policy is enforced SERVER-SIDE ONLY, at the executor
|
|
13
|
+
* dispatch chokepoint (`assertUrlAllowed` in src/mcp/tools.ts). The extension does
|
|
14
|
+
* NOT independently re-check the policy, so the bridge token is the sole trust
|
|
15
|
+
* boundary for the WebSocket: any client holding the token can issue commands the
|
|
16
|
+
* extension will execute, regardless of this policy. The policy constrains the
|
|
17
|
+
* official MCP server (and thus the LLM driving it); it is not a second line of
|
|
18
|
+
* defense against a rogue token-holding client. (A future hardening would mirror
|
|
19
|
+
* this gate inside the extension router via a policy delivered in the welcome frame.)
|
|
13
20
|
*
|
|
14
21
|
* `assertUrlAllowed(url, method, policy)` is the single chokepoint. It throws an
|
|
15
22
|
* `ExecutorError('POLICY_DENIED', …)` which the never-throw dispatch firewall
|
|
@@ -29,6 +36,8 @@ exports.DEFAULT_POLICY = Object.freeze({
|
|
|
29
36
|
allowDomains: [],
|
|
30
37
|
allowEval: false,
|
|
31
38
|
allowDownloads: false,
|
|
39
|
+
allowUploads: false,
|
|
40
|
+
uploadsDir: undefined,
|
|
32
41
|
allowAllTabs: false,
|
|
33
42
|
enableMutations: false,
|
|
34
43
|
});
|
|
@@ -38,6 +47,8 @@ function resolvePolicy(partial) {
|
|
|
38
47
|
allowDomains: partial?.allowDomains ?? [...exports.DEFAULT_POLICY.allowDomains],
|
|
39
48
|
allowEval: partial?.allowEval ?? exports.DEFAULT_POLICY.allowEval,
|
|
40
49
|
allowDownloads: partial?.allowDownloads ?? exports.DEFAULT_POLICY.allowDownloads,
|
|
50
|
+
allowUploads: partial?.allowUploads ?? exports.DEFAULT_POLICY.allowUploads,
|
|
51
|
+
uploadsDir: partial?.uploadsDir ?? exports.DEFAULT_POLICY.uploadsDir,
|
|
41
52
|
allowAllTabs: partial?.allowAllTabs ?? exports.DEFAULT_POLICY.allowAllTabs,
|
|
42
53
|
enableMutations: partial?.enableMutations ?? exports.DEFAULT_POLICY.enableMutations,
|
|
43
54
|
};
|
|
@@ -83,7 +94,11 @@ function isMutatingMethod(method) {
|
|
|
83
94
|
}
|
|
84
95
|
/** Whether the method touches a specific URL that must be allowlisted. */
|
|
85
96
|
function isUrlGated(method) {
|
|
86
|
-
return READ_CONTENT.has(method) ||
|
|
97
|
+
return (READ_CONTENT.has(method) ||
|
|
98
|
+
MUTATE_CONTENT.has(method) ||
|
|
99
|
+
NAVIGATION.has(method) ||
|
|
100
|
+
method === 'eval' ||
|
|
101
|
+
method === 'upload_file');
|
|
87
102
|
}
|
|
88
103
|
// ---------------------------------------------------------------------------
|
|
89
104
|
// Domain matching
|
|
@@ -134,6 +149,10 @@ function assertUrlAllowed(url, method, policy) {
|
|
|
134
149
|
if (method === 'download_file' && !policy.allowDownloads) {
|
|
135
150
|
throw new types_1.ExecutorError('POLICY_DENIED', 'downloads are disabled. Pass --enable-downloads or set allowDownloads.');
|
|
136
151
|
}
|
|
152
|
+
if (method === 'upload_file' && !policy.allowUploads) {
|
|
153
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'uploads are disabled (sending local files to a page is an exfiltration risk). ' +
|
|
154
|
+
'Pass --enable-uploads or set allowUploads.');
|
|
155
|
+
}
|
|
137
156
|
if (isMutatingMethod(method) && !policy.enableMutations) {
|
|
138
157
|
throw new types_1.ExecutorError('POLICY_DENIED', `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`);
|
|
139
158
|
}
|
package/docs/BLUEPRINT.md
CHANGED
|
@@ -64,7 +64,7 @@ takes over from CDP and vice-versa on disconnect.
|
|
|
64
64
|
- **The 256-bit per-boot token is the ONLY security boundary.** Origin checks and the loopback bind are defense-in-depth against *browser-page* attackers, not native processes. [RESOLVED — security blockers 2 & 3]
|
|
65
65
|
- **One canonical `protocol.ts`** imported by both server and extension. Four conflicting wire/port/token designs are collapsed into this single contract. [RESOLVED — integration blocker 1]
|
|
66
66
|
- **Helpers (`extract_links`, `read_as_markdown`, `fill_form`) are composed server-side** from primitives. Only `download_file` is an executor/wire method. [RESOLVED — integration blocker 2]
|
|
67
|
-
- **Default-deny domain policy is ON by default and gates reads too**, enforced inside the executor dispatch (both backends)
|
|
67
|
+
- **Default-deny domain policy is ON by default and gates reads too**, enforced SERVER-SIDE inside the executor dispatch (both backends). NOTE: the extension router does NOT independently re-check the policy — the bridge token is the sole trust boundary for the WebSocket. Mirroring the gate into the extension router is a planned hardening. [RESOLVED — security major 4 & eval]
|
|
68
68
|
|
|
69
69
|
---
|
|
70
70
|
|
|
@@ -540,7 +540,7 @@ threat** (this tool feeds untrusted page text to an LLM that can call `eval`).
|
|
|
540
540
|
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]
|
|
541
541
|
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]
|
|
542
542
|
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]
|
|
543
|
-
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. `assertUrlAllowed(currentTabUrl, method)` runs in **the executor dispatch (both backends)
|
|
543
|
+
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. `assertUrlAllowed(currentTabUrl, method)` runs SERVER-SIDE in **the executor dispatch (both backends)** before any attach/navigate/eval/read. The extension router does NOT re-check the policy (token is the sole bridge boundary); a mirrored extension-side gate is a planned hardening. `--unsafe-all-domains` (= `allowDomains:['*']`) is the loud-logged escape hatch. [RESOLVED — major 4]
|
|
544
544
|
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]
|
|
545
545
|
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]
|
|
546
546
|
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]
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"eval",
|
|
27
27
|
"wait_for",
|
|
28
28
|
"download_file",
|
|
29
|
+
"upload_file",
|
|
29
30
|
"ping_probe"
|
|
30
31
|
];
|
|
31
32
|
|
|
@@ -174,7 +175,22 @@
|
|
|
174
175
|
const r = el.getBoundingClientRect();
|
|
175
176
|
if (r.width === 0 && r.height === 0) return false;
|
|
176
177
|
const s = window.getComputedStyle(el);
|
|
177
|
-
|
|
178
|
+
if (s.visibility === "hidden" || s.display === "none") return false;
|
|
179
|
+
const cv = el.checkVisibility;
|
|
180
|
+
if (typeof cv === "function") {
|
|
181
|
+
try {
|
|
182
|
+
return cv.call(el, { checkOpacity: false, checkVisibilityCSS: true });
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let p = el.parentElement;
|
|
187
|
+
while (p) {
|
|
188
|
+
const ps = window.getComputedStyle(p);
|
|
189
|
+
if (ps.display === "none" || ps.visibility === "hidden") return false;
|
|
190
|
+
p = p.parentElement;
|
|
191
|
+
}
|
|
192
|
+
if (el.offsetParent === null && s.position !== "fixed") return false;
|
|
193
|
+
return true;
|
|
178
194
|
};
|
|
179
195
|
const accName = (el) => {
|
|
180
196
|
const aria = el.getAttribute("aria-label");
|
|
@@ -210,7 +226,36 @@
|
|
|
210
226
|
}
|
|
211
227
|
return tag;
|
|
212
228
|
};
|
|
213
|
-
const
|
|
229
|
+
const seen = /* @__PURE__ */ new Set();
|
|
230
|
+
const candidates = [];
|
|
231
|
+
const collect = (root) => {
|
|
232
|
+
if (candidates.length >= max) return;
|
|
233
|
+
let matched;
|
|
234
|
+
try {
|
|
235
|
+
matched = Array.from(root.querySelectorAll(sel));
|
|
236
|
+
} catch {
|
|
237
|
+
matched = [];
|
|
238
|
+
}
|
|
239
|
+
for (const el of matched) {
|
|
240
|
+
if (candidates.length >= max) break;
|
|
241
|
+
if (seen.has(el)) continue;
|
|
242
|
+
seen.add(el);
|
|
243
|
+
candidates.push(el);
|
|
244
|
+
}
|
|
245
|
+
let hosts;
|
|
246
|
+
try {
|
|
247
|
+
hosts = Array.from(root.querySelectorAll("*"));
|
|
248
|
+
} catch {
|
|
249
|
+
hosts = [];
|
|
250
|
+
}
|
|
251
|
+
for (const host of hosts) {
|
|
252
|
+
if (candidates.length >= max) break;
|
|
253
|
+
const sr = host.shadowRoot;
|
|
254
|
+
if (sr) collect(sr);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
collect(document);
|
|
258
|
+
const els = candidates.filter(visible);
|
|
214
259
|
const nodes = [];
|
|
215
260
|
let n = 0;
|
|
216
261
|
for (const el of els) {
|
|
@@ -377,6 +422,7 @@
|
|
|
377
422
|
"eval",
|
|
378
423
|
"wait_for",
|
|
379
424
|
"download_file",
|
|
425
|
+
"upload_file",
|
|
380
426
|
"ping_probe"
|
|
381
427
|
]);
|
|
382
428
|
var ChromeExecutor = class {
|
|
@@ -396,8 +442,19 @@
|
|
|
396
442
|
}
|
|
397
443
|
case "tab_new": {
|
|
398
444
|
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
445
|
+
const BLANK = /^(about:blank|chrome:\/\/newtab|chrome:\/\/new-tab-page|edge:\/\/newtab)/i;
|
|
446
|
+
const blank = (await chrome.tabs.query({})).find(
|
|
447
|
+
(t2) => t2.id !== void 0 && (BLANK.test(t2.url ?? "") || (t2.url ?? "") === "" || t2.pendingUrl === "about:blank")
|
|
448
|
+
);
|
|
449
|
+
if (blank?.id !== void 0) {
|
|
450
|
+
if (url) {
|
|
451
|
+
await chrome.tabs.update(blank.id, { url });
|
|
452
|
+
await waitComplete(blank.id);
|
|
453
|
+
}
|
|
454
|
+
return { ...await tabInfo(await chrome.tabs.get(blank.id)), reused: true };
|
|
455
|
+
}
|
|
399
456
|
const t = await chrome.tabs.create({ url, active: false });
|
|
400
|
-
return tabInfo(t);
|
|
457
|
+
return { ...await tabInfo(t), reused: false };
|
|
401
458
|
}
|
|
402
459
|
case "tab_close": {
|
|
403
460
|
const id = parseTabId(String(cmd.tabId));
|
|
@@ -732,6 +789,23 @@
|
|
|
732
789
|
const downloadId = await chrome.downloads.download({ url, filename: name });
|
|
733
790
|
return { path: `(downloads)/${name}`, backend: "extension", bytes: 0, suggestedName: name };
|
|
734
791
|
}
|
|
792
|
+
// -- upload: set local file(s) on a file <input> via CDP DOM.setFileInputFiles --
|
|
793
|
+
case "upload_file": {
|
|
794
|
+
const id = await targetTab(cmd);
|
|
795
|
+
const sel = requireSelector(cmd);
|
|
796
|
+
const files = Array.isArray(cmd.params.files) ? cmd.params.files.map(String) : [];
|
|
797
|
+
if (files.length === 0) throw new CmdError("BAD_ARGS", 'upload_file requires a non-empty "files" array');
|
|
798
|
+
if (!await waitForSelector(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
799
|
+
await withDebugger(id, async (t) => {
|
|
800
|
+
const doc = await chrome.debugger.sendCommand(t, "DOM.getDocument", { depth: 0 });
|
|
801
|
+
const rootId = doc.root?.nodeId;
|
|
802
|
+
if (!rootId) throw new CmdError("CDP_ERROR", "could not read the document root");
|
|
803
|
+
const found = await chrome.debugger.sendCommand(t, "DOM.querySelector", { nodeId: rootId, selector: sel });
|
|
804
|
+
if (!found.nodeId) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
805
|
+
await chrome.debugger.sendCommand(t, "DOM.setFileInputFiles", { files, nodeId: found.nodeId });
|
|
806
|
+
});
|
|
807
|
+
return { ok: true };
|
|
808
|
+
}
|
|
735
809
|
default:
|
|
736
810
|
throw new CmdError("UNKNOWN_METHOD", `unhandled method: ${cmd.method}`);
|
|
737
811
|
}
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome MCP Bridge",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
6
|
"minimum_chrome_version": "116",
|
|
7
|
-
"background": {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
"background": {
|
|
8
|
+
"service_worker": "background.js"
|
|
9
|
+
},
|
|
10
|
+
"permissions": [
|
|
11
|
+
"tabs",
|
|
12
|
+
"scripting",
|
|
13
|
+
"activeTab",
|
|
14
|
+
"downloads",
|
|
15
|
+
"storage",
|
|
16
|
+
"alarms",
|
|
17
|
+
"cookies",
|
|
18
|
+
"debugger"
|
|
19
|
+
],
|
|
20
|
+
"host_permissions": [
|
|
21
|
+
"<all_urls>"
|
|
22
|
+
],
|
|
10
23
|
"options_page": "options.html",
|
|
11
|
-
"action": {
|
|
24
|
+
"action": {
|
|
25
|
+
"default_title": "Chrome MCP — open options to pair"
|
|
26
|
+
}
|
|
12
27
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Drive a real Chrome browser over MCP. A stdio MCP server (CLI) plus an MV3 extension, behind one pluggable Executor (extension via chrome.scripting, or a Playwright CDP fallback).",
|
|
5
5
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
6
6
|
"license": "MIT",
|