@mehmoodqureshi/chrome-mcp 0.4.1 → 0.5.0
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 +51 -11
- package/dist/shared/mutex.d.ts +18 -0
- package/dist/shared/mutex.js +42 -0
- package/dist/shared/policy.d.ts +34 -0
- package/dist/shared/policy.js +151 -0
- package/dist/shared/protocol.d.ts +15 -0
- package/dist/shared/screenshot.d.ts +58 -0
- package/dist/shared/screenshot.js +54 -0
- package/dist/src/bridge/server.d.ts +4 -1
- package/dist/src/bridge/server.js +2 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -1
- package/dist/src/executor/cdp-executor.js +3 -1
- package/dist/src/executor/extension-executor.d.ts +3 -1
- package/dist/src/executor/extension-executor.js +2 -2
- package/dist/src/executor/stub-executor.d.ts +3 -1
- package/dist/src/executor/stub-executor.js +1 -1
- package/dist/src/executor/types.d.ts +4 -1
- package/dist/src/mcp/batch.d.ts +26 -0
- package/dist/src/mcp/batch.js +130 -0
- package/dist/src/mcp/tools.js +29 -3
- package/dist/src/security/policy.d.ts +16 -34
- package/dist/src/security/policy.js +20 -124
- package/docs/BLUEPRINT.md +9 -4
- package/extension-dist/background.js +281 -42
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/batch.ts — the `batch` fan-out tool: run many tool calls in one
|
|
4
|
+
* request, in parallel (default) or serially.
|
|
5
|
+
*
|
|
6
|
+
* Pure server-side composition: every sub-op is routed back through the same
|
|
7
|
+
* `dispatchToolCall` firewall, so it inherits the policy gate (server AND
|
|
8
|
+
* extension), the rate limiter, the executor-ready guard, and never-throw error
|
|
9
|
+
* rendering. There is no security bypass — a batch of N tool calls is exactly N
|
|
10
|
+
* ordinary tool calls that happen to be issued together.
|
|
11
|
+
*
|
|
12
|
+
* Concurrency safety: in parallel mode a tab-scoped sub-op that omits `tabId`
|
|
13
|
+
* would fall back to the shared "active tab" pointer, which races under
|
|
14
|
+
* concurrency (see docs/BLUEPRINT.md and the SW executor's active-tab default).
|
|
15
|
+
* So parallel mode REQUIRES an explicit `tabId` on tab-scoped ops; the op is
|
|
16
|
+
* rejected (as its own isError result) rather than silently mis-routed.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.runBatch = runBatch;
|
|
20
|
+
const envelopes_1 = require("./envelopes");
|
|
21
|
+
const validators_1 = require("./validators");
|
|
22
|
+
/** Hard cap on operations per batch — bounds memory (results accumulate) and blast radius. */
|
|
23
|
+
const MAX_OPS = 50;
|
|
24
|
+
const DEFAULT_CONCURRENCY = 6;
|
|
25
|
+
const MAX_CONCURRENCY = 16;
|
|
26
|
+
/** Validate the `ops` envelope. Structural problems throw (the whole batch is
|
|
27
|
+
* malformed); per-op semantic problems are handled later as per-op errors. */
|
|
28
|
+
function parseOps(raw) {
|
|
29
|
+
if (!Array.isArray(raw))
|
|
30
|
+
throw new validators_1.McpToolError('"ops" must be an array of { tool, args } objects');
|
|
31
|
+
if (raw.length === 0)
|
|
32
|
+
throw new validators_1.McpToolError('"ops" must contain at least one operation');
|
|
33
|
+
if (raw.length > MAX_OPS)
|
|
34
|
+
throw new validators_1.McpToolError(`"ops" has ${raw.length} operations; the max is ${MAX_OPS}`);
|
|
35
|
+
return raw.map((o, i) => {
|
|
36
|
+
if (typeof o !== 'object' || o === null || Array.isArray(o)) {
|
|
37
|
+
throw new validators_1.McpToolError(`ops[${i}] must be an object with a "tool" and optional "args"`);
|
|
38
|
+
}
|
|
39
|
+
const rec = o;
|
|
40
|
+
if (typeof rec.tool !== 'string' || rec.tool.length === 0) {
|
|
41
|
+
throw new validators_1.McpToolError(`ops[${i}].tool must be a non-empty string`);
|
|
42
|
+
}
|
|
43
|
+
if (rec.args !== undefined && (typeof rec.args !== 'object' || rec.args === null || Array.isArray(rec.args))) {
|
|
44
|
+
throw new validators_1.McpToolError(`ops[${i}].args must be an object`);
|
|
45
|
+
}
|
|
46
|
+
return { tool: rec.tool, args: rec.args ?? {} };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Map with bounded concurrency. `fn` never throws (dispatch is the firewall). */
|
|
50
|
+
async function mapLimit(items, limit, fn) {
|
|
51
|
+
const out = new Array(items.length);
|
|
52
|
+
let next = 0;
|
|
53
|
+
const worker = async () => {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const i = next++;
|
|
56
|
+
if (i >= items.length)
|
|
57
|
+
return;
|
|
58
|
+
out[i] = await fn(items[i], i);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
async function runBatch(rawArgs, deps) {
|
|
65
|
+
const a = (0, validators_1.asArgs)(rawArgs);
|
|
66
|
+
const ops = parseOps(a.ops);
|
|
67
|
+
const mode = ((0, validators_1.optionalString)(a, 'mode') ?? 'parallel');
|
|
68
|
+
if (mode !== 'parallel' && mode !== 'serial') {
|
|
69
|
+
throw new validators_1.McpToolError('"mode" must be "parallel" or "serial"');
|
|
70
|
+
}
|
|
71
|
+
const stopOnError = (0, validators_1.optionalBoolean)(a, 'stopOnError') ?? false;
|
|
72
|
+
const concurrency = (0, validators_1.optionalNumber)(a, 'maxConcurrency', { min: 1, max: MAX_CONCURRENCY }) ?? DEFAULT_CONCURRENCY;
|
|
73
|
+
/** Run one op through the firewall, after the per-op guards. Never throws. */
|
|
74
|
+
const runOne = async (op) => {
|
|
75
|
+
if (op.tool === 'batch')
|
|
76
|
+
return (0, envelopes_1.errorResult)('batch cannot be nested inside batch');
|
|
77
|
+
if (mode === 'parallel' && deps.requiresExplicitTab(op.tool) && op.args.tabId == null) {
|
|
78
|
+
return (0, envelopes_1.errorResult)(`"${op.tool}" needs an explicit "tabId" in a parallel batch — the active-tab default is unsafe under concurrency (use serial mode, or pass tabId)`);
|
|
79
|
+
}
|
|
80
|
+
// In a parallel batch, default new tabs to the background so N concurrent
|
|
81
|
+
// opens don't fight over window focus (a single tab_new still focuses).
|
|
82
|
+
let args = op.args;
|
|
83
|
+
if (mode === 'parallel' && op.tool === 'tab_new' && args.active === undefined) {
|
|
84
|
+
args = { ...args, active: false };
|
|
85
|
+
}
|
|
86
|
+
return deps.dispatch(op.tool, args);
|
|
87
|
+
};
|
|
88
|
+
let outcomes;
|
|
89
|
+
if (mode === 'serial') {
|
|
90
|
+
outcomes = ops.map(() => ({ status: 'skipped' }));
|
|
91
|
+
for (let i = 0; i < ops.length; i++) {
|
|
92
|
+
const result = await runOne(ops[i]);
|
|
93
|
+
outcomes[i] = { status: result.isError ? 'error' : 'ok', result };
|
|
94
|
+
if (stopOnError && result.isError)
|
|
95
|
+
break; // leave the rest 'skipped'
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const results = await mapLimit(ops, concurrency, (op) => runOne(op));
|
|
100
|
+
outcomes = results.map((result) => ({ status: result.isError ? 'error' : 'ok', result }));
|
|
101
|
+
}
|
|
102
|
+
return renderBatch(ops, outcomes, mode);
|
|
103
|
+
}
|
|
104
|
+
/** Compose the per-op outcomes into one MCP result: a JSON summary block first,
|
|
105
|
+
* then each executed op's own content blocks (text/images flow through intact). */
|
|
106
|
+
function renderBatch(ops, outcomes, mode) {
|
|
107
|
+
const summary = outcomes.map((o, i) => ({ index: i, tool: ops[i].tool, status: o.status }));
|
|
108
|
+
const counts = {
|
|
109
|
+
total: ops.length,
|
|
110
|
+
ok: summary.filter((s) => s.status === 'ok').length,
|
|
111
|
+
error: summary.filter((s) => s.status === 'error').length,
|
|
112
|
+
skipped: summary.filter((s) => s.status === 'skipped').length,
|
|
113
|
+
};
|
|
114
|
+
const content = [
|
|
115
|
+
{ type: 'text', text: JSON.stringify({ batch: { mode, ...counts }, results: summary }, null, 2) },
|
|
116
|
+
];
|
|
117
|
+
for (let i = 0; i < outcomes.length; i++) {
|
|
118
|
+
const o = outcomes[i];
|
|
119
|
+
if (!o.result)
|
|
120
|
+
continue; // skipped ops carry no payload
|
|
121
|
+
content.push({ type: 'text', text: `--- op ${i} (${ops[i].tool}) ${o.status} ---` });
|
|
122
|
+
for (const block of o.result.content)
|
|
123
|
+
content.push(block);
|
|
124
|
+
}
|
|
125
|
+
// The batch ran successfully even if some ops failed; only flag isError when
|
|
126
|
+
// nothing succeeded, so a host sees partial success as success.
|
|
127
|
+
const isError = ops.length > 0 && counts.ok === 0;
|
|
128
|
+
return isError ? { content, isError: true } : { content };
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=batch.js.map
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -22,6 +22,7 @@ const types_1 = require("../executor/types");
|
|
|
22
22
|
const manager_1 = require("../executor/manager");
|
|
23
23
|
const policy_1 = require("../security/policy");
|
|
24
24
|
const envelopes_1 = require("./envelopes");
|
|
25
|
+
const batch_1 = require("./batch");
|
|
25
26
|
const helpers_1 = require("./helpers");
|
|
26
27
|
const validators_1 = require("./validators");
|
|
27
28
|
const TARGET_PROPS = {
|
|
@@ -37,9 +38,9 @@ const obj = (properties, required = []) => ({
|
|
|
37
38
|
exports.TOOL_DEFINITIONS = [
|
|
38
39
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: obj({}) },
|
|
39
40
|
{ name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
|
|
40
|
-
{ name: 'tab_new', description: 'Open a
|
|
41
|
+
{ name: 'tab_new', description: 'Open a NEW tab (optionally at a URL) and focus it. Prefer this over `navigate` when the user says "open"/"go to" a site — `navigate` REPLACES the current tab. Pass active:false to open in the background (used by parallel batches).', inputSchema: obj({ url: { type: 'string' }, active: { type: 'boolean' } }) },
|
|
41
42
|
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
|
|
42
|
-
{ name: 'navigate', description: 'Navigate the active
|
|
43
|
+
{ name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: obj({ url: { type: 'string' }, tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }, ['url']) },
|
|
43
44
|
{ name: 'back', description: 'Go back in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
|
|
44
45
|
{ name: 'forward', description: 'Go forward in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
|
|
45
46
|
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: obj({ tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }) },
|
|
@@ -63,6 +64,20 @@ exports.TOOL_DEFINITIONS = [
|
|
|
63
64
|
{ 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
65
|
{ 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']) },
|
|
65
66
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
|
|
67
|
+
{
|
|
68
|
+
name: 'batch',
|
|
69
|
+
description: 'Run multiple tool calls in one request — parallel (default) or serial. Each op is { tool, args } and goes through the same policy gate, rate limit, and error handling as a direct call. In parallel mode, tab-scoped ops MUST pass an explicit tabId (the active-tab default is unsafe under concurrency). Use to drive several tabs at once (e.g. open tabs, then batch get_text across them). Cannot be nested.',
|
|
70
|
+
inputSchema: obj({
|
|
71
|
+
ops: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
description: 'Operations to run; each is a tool name + its args.',
|
|
74
|
+
items: obj({ tool: { type: 'string' }, args: { type: 'object' } }, ['tool']),
|
|
75
|
+
},
|
|
76
|
+
mode: { type: 'string', enum: ['parallel', 'serial'], description: 'Default "parallel".' },
|
|
77
|
+
stopOnError: { type: 'boolean', description: 'Serial mode only: stop after the first failing op (the rest are skipped).' },
|
|
78
|
+
maxConcurrency: { type: 'number', description: 'Parallel mode: max ops in flight at once (default 6).' },
|
|
79
|
+
}, ['ops']),
|
|
80
|
+
},
|
|
66
81
|
];
|
|
67
82
|
/** Resolve the URL the policy should be evaluated against (the active tab). */
|
|
68
83
|
async function activeUrl(ex) {
|
|
@@ -81,6 +96,14 @@ async function gate(ctx, method, urlOverride) {
|
|
|
81
96
|
}
|
|
82
97
|
const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
|
|
83
98
|
const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
99
|
+
/** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
|
|
100
|
+
* the parallel-batch explicit-tabId requirement. Everything else falls back to
|
|
101
|
+
* the active tab when `tabId` is omitted, which races under concurrency. */
|
|
102
|
+
const PARALLEL_TAB_EXEMPT = new Set(['tabs_list', 'tab_new', 'chrome_status', 'batch']);
|
|
103
|
+
/** A known tool that operates on a specific tab — needs an explicit tabId in a parallel batch. */
|
|
104
|
+
function requiresExplicitTab(tool) {
|
|
105
|
+
return tool in exports.TOOL_HANDLERS && !PARALLEL_TAB_EXEMPT.has(tool);
|
|
106
|
+
}
|
|
84
107
|
exports.TOOL_HANDLERS = {
|
|
85
108
|
tabs_list: async (_a, ctx) => (0, envelopes_1.jsonResult)(await ctx.ex.tabsList()),
|
|
86
109
|
tab_select: async (a, ctx) => {
|
|
@@ -89,7 +112,7 @@ exports.TOOL_HANDLERS = {
|
|
|
89
112
|
},
|
|
90
113
|
tab_new: async (a, ctx) => {
|
|
91
114
|
await gate(ctx, 'tab_new');
|
|
92
|
-
return (0, envelopes_1.jsonResult)(await ctx.ex.tabNew((0, validators_1.optionalString)(a, 'url')));
|
|
115
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.tabNew((0, validators_1.optionalString)(a, 'url'), { active: (0, validators_1.optionalBoolean)(a, 'active') }));
|
|
93
116
|
},
|
|
94
117
|
tab_close: async (a, ctx) => {
|
|
95
118
|
await gate(ctx, 'tab_close');
|
|
@@ -285,6 +308,9 @@ exports.TOOL_HANDLERS = {
|
|
|
285
308
|
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
286
309
|
},
|
|
287
310
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
311
|
+
// Fan-out: each sub-op is routed back through `dispatchToolCall`, so it gets
|
|
312
|
+
// the same policy gate, rate limit, and never-throw handling as a direct call.
|
|
313
|
+
batch: async (a) => (0, batch_1.runBatch)(a, { dispatch: dispatchToolCall, requiresExplicitTab }),
|
|
288
314
|
};
|
|
289
315
|
// ---------------------------------------------------------------------------
|
|
290
316
|
// Dispatch (never-throw firewall)
|
|
@@ -6,54 +6,36 @@
|
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.)
|
|
9
|
+
* - gates READS as well as writes (reads are the exfil payload),
|
|
10
|
+
* - is enforced at BOTH ends. The decision lives in `shared/policy.ts`
|
|
11
|
+
* (`evaluatePolicy`); the server wraps it here (`assertUrlAllowed`) and the
|
|
12
|
+
* extension router runs the SAME function against the policy delivered in the
|
|
13
|
+
* `welcome` frame. Because both ends call one shared function, they cannot
|
|
14
|
+
* drift, and a client that bypasses the server still hits the extension gate.
|
|
15
|
+
* (The bridge token remains the PRIMARY trust boundary for the WebSocket;
|
|
16
|
+
* the extension gate is defense-in-depth on top of it.)
|
|
19
17
|
*
|
|
20
18
|
* `assertUrlAllowed(url, method, policy)` is the single chokepoint. It throws an
|
|
21
19
|
* `ExecutorError('POLICY_DENIED', …)` which the never-throw dispatch firewall
|
|
22
20
|
* renders as a structured MCP error.
|
|
23
21
|
*/
|
|
24
|
-
import type { WireMethod } from '../../shared/protocol';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/** Allow `download_file`. */
|
|
31
|
-
allowDownloads: boolean;
|
|
32
|
-
/** Allow `upload_file` (sends local files to the page — exfiltration risk). */
|
|
33
|
-
allowUploads: boolean;
|
|
22
|
+
import type { WireMethod, WirePolicy } from '../../shared/protocol';
|
|
23
|
+
import { isReadMethod, isMutatingMethod, isUrlGated, hostOf, isDomainAllowed } from '../../shared/policy';
|
|
24
|
+
export { isReadMethod, isMutatingMethod, isUrlGated, hostOf, isDomainAllowed };
|
|
25
|
+
/** The full server-side policy: the wire-serializable {@link WirePolicy} plus the
|
|
26
|
+
* server-only `uploadsDir` (a local path, never sent to the extension). */
|
|
27
|
+
export interface Policy extends WirePolicy {
|
|
34
28
|
/** If set, `upload_file` may only read files inside this directory (absolute path). */
|
|
35
29
|
uploadsDir?: string;
|
|
36
|
-
/** Allow acting on / reading tabs whose URL is not in `allowDomains` is governed
|
|
37
|
-
* by `allowDomains`; this flag instead relaxes tab *management* (list/select)
|
|
38
|
-
* to all tabs regardless of their URL. Default false. */
|
|
39
|
-
allowAllTabs: boolean;
|
|
40
|
-
/** Safe-mode master switch for the mutating tool set (click/type/navigate/…). */
|
|
41
|
-
enableMutations: boolean;
|
|
42
30
|
}
|
|
43
31
|
/** The SAFE default: deny everything until the user opts in. */
|
|
44
32
|
export declare const DEFAULT_POLICY: Readonly<Policy>;
|
|
45
33
|
/** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
|
|
46
34
|
export declare function resolvePolicy(partial?: Partial<Policy>): Policy;
|
|
47
|
-
export declare function isReadMethod(method: WireMethod): boolean;
|
|
48
|
-
/** Everything in the mutating tool set that safe-mode disables. `eval` is gated
|
|
49
|
-
* separately via `allowEval`; `download_file` separately via `allowDownloads`. */
|
|
50
|
-
export declare function isMutatingMethod(method: WireMethod): boolean;
|
|
51
|
-
/** Parse a host out of a URL string; '' if it has none (about:blank, data:, …). */
|
|
52
|
-
export declare function hostOf(url: string): string;
|
|
53
|
-
export declare function isDomainAllowed(url: string, policy: Policy): boolean;
|
|
54
35
|
/**
|
|
55
36
|
* Throw `POLICY_DENIED` unless `method` against `url` is permitted by `policy`.
|
|
56
37
|
* `url` is the DESTINATION for navigation, otherwise the current tab URL.
|
|
57
|
-
*
|
|
38
|
+
* Thin server-side wrapper over the shared, pure `evaluatePolicy` — the SAME
|
|
39
|
+
* function the extension router runs, so the two ends cannot drift.
|
|
58
40
|
*/
|
|
59
41
|
export declare function assertUrlAllowed(url: string, method: WireMethod, policy: Policy): void;
|
|
@@ -7,30 +7,30 @@
|
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.)
|
|
10
|
+
* - gates READS as well as writes (reads are the exfil payload),
|
|
11
|
+
* - is enforced at BOTH ends. The decision lives in `shared/policy.ts`
|
|
12
|
+
* (`evaluatePolicy`); the server wraps it here (`assertUrlAllowed`) and the
|
|
13
|
+
* extension router runs the SAME function against the policy delivered in the
|
|
14
|
+
* `welcome` frame. Because both ends call one shared function, they cannot
|
|
15
|
+
* drift, and a client that bypasses the server still hits the extension gate.
|
|
16
|
+
* (The bridge token remains the PRIMARY trust boundary for the WebSocket;
|
|
17
|
+
* the extension gate is defense-in-depth on top of it.)
|
|
20
18
|
*
|
|
21
19
|
* `assertUrlAllowed(url, method, policy)` is the single chokepoint. It throws an
|
|
22
20
|
* `ExecutorError('POLICY_DENIED', …)` which the never-throw dispatch firewall
|
|
23
21
|
* renders as a structured MCP error.
|
|
24
22
|
*/
|
|
25
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.DEFAULT_POLICY = void 0;
|
|
24
|
+
exports.DEFAULT_POLICY = exports.isDomainAllowed = exports.hostOf = exports.isUrlGated = exports.isMutatingMethod = exports.isReadMethod = void 0;
|
|
27
25
|
exports.resolvePolicy = resolvePolicy;
|
|
28
|
-
exports.isReadMethod = isReadMethod;
|
|
29
|
-
exports.isMutatingMethod = isMutatingMethod;
|
|
30
|
-
exports.hostOf = hostOf;
|
|
31
|
-
exports.isDomainAllowed = isDomainAllowed;
|
|
32
26
|
exports.assertUrlAllowed = assertUrlAllowed;
|
|
33
27
|
const types_1 = require("../executor/types");
|
|
28
|
+
const policy_1 = require("../../shared/policy");
|
|
29
|
+
Object.defineProperty(exports, "isReadMethod", { enumerable: true, get: function () { return policy_1.isReadMethod; } });
|
|
30
|
+
Object.defineProperty(exports, "isMutatingMethod", { enumerable: true, get: function () { return policy_1.isMutatingMethod; } });
|
|
31
|
+
Object.defineProperty(exports, "isUrlGated", { enumerable: true, get: function () { return policy_1.isUrlGated; } });
|
|
32
|
+
Object.defineProperty(exports, "hostOf", { enumerable: true, get: function () { return policy_1.hostOf; } });
|
|
33
|
+
Object.defineProperty(exports, "isDomainAllowed", { enumerable: true, get: function () { return policy_1.isDomainAllowed; } });
|
|
34
34
|
/** The SAFE default: deny everything until the user opts in. */
|
|
35
35
|
exports.DEFAULT_POLICY = Object.freeze({
|
|
36
36
|
allowDomains: [],
|
|
@@ -54,121 +54,17 @@ function resolvePolicy(partial) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
57
|
-
// Method classification
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
/** Methods that read page CONTENT (the exfil payload) — URL-gated. */
|
|
60
|
-
const READ_CONTENT = new Set([
|
|
61
|
-
'get_text',
|
|
62
|
-
'get_html',
|
|
63
|
-
'screenshot',
|
|
64
|
-
'wait_for',
|
|
65
|
-
]);
|
|
66
|
-
/** Content-mutating actions — URL-gated AND mutation-gated. */
|
|
67
|
-
const MUTATE_CONTENT = new Set([
|
|
68
|
-
'click',
|
|
69
|
-
'type',
|
|
70
|
-
'press',
|
|
71
|
-
'hover',
|
|
72
|
-
'scroll',
|
|
73
|
-
]);
|
|
74
|
-
/** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
|
|
75
|
-
const NAVIGATION = new Set([
|
|
76
|
-
'navigate',
|
|
77
|
-
'back',
|
|
78
|
-
'forward',
|
|
79
|
-
'reload',
|
|
80
|
-
]);
|
|
81
|
-
/** Tab management — mutation-gated, but not content-URL-gated (unless allowAllTabs is off). */
|
|
82
|
-
const TAB_MUTATE = new Set([
|
|
83
|
-
'tab_select',
|
|
84
|
-
'tab_new',
|
|
85
|
-
'tab_close',
|
|
86
|
-
]);
|
|
87
|
-
function isReadMethod(method) {
|
|
88
|
-
return READ_CONTENT.has(method) || method === 'tabs_list';
|
|
89
|
-
}
|
|
90
|
-
/** Everything in the mutating tool set that safe-mode disables. `eval` is gated
|
|
91
|
-
* separately via `allowEval`; `download_file` separately via `allowDownloads`. */
|
|
92
|
-
function isMutatingMethod(method) {
|
|
93
|
-
return MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || TAB_MUTATE.has(method);
|
|
94
|
-
}
|
|
95
|
-
/** Whether the method touches a specific URL that must be allowlisted. */
|
|
96
|
-
function isUrlGated(method) {
|
|
97
|
-
return (READ_CONTENT.has(method) ||
|
|
98
|
-
MUTATE_CONTENT.has(method) ||
|
|
99
|
-
NAVIGATION.has(method) ||
|
|
100
|
-
method === 'eval' ||
|
|
101
|
-
method === 'upload_file');
|
|
102
|
-
}
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// Domain matching
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
/** Parse a host out of a URL string; '' if it has none (about:blank, data:, …). */
|
|
107
|
-
function hostOf(url) {
|
|
108
|
-
try {
|
|
109
|
-
return new URL(url).hostname.toLowerCase();
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
return '';
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
function isAboutBlank(url) {
|
|
116
|
-
return url === 'about:blank' || url === '' || url.startsWith('about:');
|
|
117
|
-
}
|
|
118
|
-
/** Convert a single domain glob to a predicate. '*' matches everything;
|
|
119
|
-
* '*.example.com' matches example.com and any subdomain; otherwise exact host. */
|
|
120
|
-
function globMatches(host, pattern) {
|
|
121
|
-
const p = pattern.trim().toLowerCase();
|
|
122
|
-
if (p === '*' || p === '*://*/*')
|
|
123
|
-
return true;
|
|
124
|
-
if (p.startsWith('*.')) {
|
|
125
|
-
const base = p.slice(2);
|
|
126
|
-
return host === base || host.endsWith('.' + base);
|
|
127
|
-
}
|
|
128
|
-
return host === p;
|
|
129
|
-
}
|
|
130
|
-
function isDomainAllowed(url, policy) {
|
|
131
|
-
const host = hostOf(url);
|
|
132
|
-
if (!host)
|
|
133
|
-
return false;
|
|
134
|
-
return policy.allowDomains.some((pat) => globMatches(host, pat));
|
|
135
|
-
}
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
57
|
// The gate
|
|
138
58
|
// ---------------------------------------------------------------------------
|
|
139
59
|
/**
|
|
140
60
|
* Throw `POLICY_DENIED` unless `method` against `url` is permitted by `policy`.
|
|
141
61
|
* `url` is the DESTINATION for navigation, otherwise the current tab URL.
|
|
142
|
-
*
|
|
62
|
+
* Thin server-side wrapper over the shared, pure `evaluatePolicy` — the SAME
|
|
63
|
+
* function the extension router runs, so the two ends cannot drift.
|
|
143
64
|
*/
|
|
144
65
|
function assertUrlAllowed(url, method, policy) {
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
throw new types_1.ExecutorError('POLICY_DENIED',
|
|
148
|
-
}
|
|
149
|
-
if (method === 'download_file' && !policy.allowDownloads) {
|
|
150
|
-
throw new types_1.ExecutorError('POLICY_DENIED', 'downloads are disabled. Pass --enable-downloads or set allowDownloads.');
|
|
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
|
-
}
|
|
156
|
-
if (isMutatingMethod(method) && !policy.enableMutations) {
|
|
157
|
-
throw new types_1.ExecutorError('POLICY_DENIED', `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`);
|
|
158
|
-
}
|
|
159
|
-
// -- tab management without allowAllTabs still needs the target tab's URL allowlisted,
|
|
160
|
-
// but list/select/close don't carry a content URL here; treat them as allowed once
|
|
161
|
-
// the mutation gate above has passed (URL-gating of their effect happens on the
|
|
162
|
-
// subsequent content op). --
|
|
163
|
-
if (!isUrlGated(method))
|
|
164
|
-
return;
|
|
165
|
-
// Navigating to a blank page is always fine.
|
|
166
|
-
if (isAboutBlank(url) && NAVIGATION.has(method))
|
|
167
|
-
return;
|
|
168
|
-
if (!isDomainAllowed(url, policy)) {
|
|
169
|
-
const host = hostOf(url) || url;
|
|
170
|
-
throw new types_1.ExecutorError('POLICY_DENIED', `"${method}" denied: ${host} is not in the domain allowlist. ` +
|
|
171
|
-
`Add it to allowDomains, or pass --unsafe-all-domains.`);
|
|
172
|
-
}
|
|
66
|
+
const verdict = (0, policy_1.evaluatePolicy)(url, method, policy);
|
|
67
|
+
if (!verdict.ok)
|
|
68
|
+
throw new types_1.ExecutorError('POLICY_DENIED', verdict.reason);
|
|
173
69
|
}
|
|
174
70
|
//# sourceMappingURL=policy.js.map
|
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
|
|
67
|
+
- **Default-deny domain policy is ON by default and gates reads too**, enforced at BOTH ends via the shared `evaluatePolicy` (`shared/policy.ts`): the server wraps it in the executor dispatch, and the extension router runs the same function against the policy delivered in the `welcome` frame. The bridge token remains the primary trust boundary; the extension gate is defense-in-depth. [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. `
|
|
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. 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]
|
|
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]
|
|
@@ -588,9 +588,14 @@ Build: `files` whitelist incl. `extension-dist/`, `--print-pairing`/`--print-ext
|
|
|
588
588
|
- **Native-messaging trampoline install step** is the one manual setup beyond `npx`; the manual file-path paste is the no-native fallback. Smoother one-click pairing is a v1.1 polish.
|
|
589
589
|
- **`networkidle`** is approximated by a bounded idle-window poll (no native CDP event); documented as best-effort, never able to wedge a call.
|
|
590
590
|
- **Local-code-execution attacker** who can already read the user's 0600 files has root-equivalent access to the user session; the token cannot defend against an attacker who already owns the filesystem. The policy allowlist still blocks blind exfil to arbitrary domains.
|
|
591
|
-
- **`captureBeyondViewport` very-tall pages**:
|
|
591
|
+
- **`captureBeyondViewport` very-tall pages**: full-page capture now ships (extension `screenshot` uses `chrome.debugger Page.captureScreenshot` with a content-box clip); only pages taller than the ~16384px skia ceiling are clamped + `truncated`-flagged. [RESOLVED — v0.5.0]
|
|
592
|
+
|
|
593
|
+
**Resolved in v0.5.0 (safe multi-tab concurrency):**
|
|
594
|
+
- **Screenshot active-tab race (H1):** the extension captured via `captureVisibleTab`, which had to activate the target tab — concurrent captures stole focus and could grab the wrong tab. Now `chrome.debugger Page.captureScreenshot` captures a specific tab without activating it.
|
|
595
|
+
- **Active-tab default under concurrency (H2):** the `batch` fan-out tool requires an explicit `tabId` on tab-scoped ops in `parallel` mode (rejected rather than mis-routed to whatever tab is frontmost).
|
|
596
|
+
- **Per-tab debugger collisions + `tab_new` race (H3/H4):** a `KeyedMutex` serializes `chrome.debugger` attach/detach per tab and the `tab_new` blank-tab claim; different tabs still run in parallel.
|
|
597
|
+
- Per-cmd `tabId` addressing is on every wire method, so multi-tab works without a backend pool. A true multi-*session* `Executor` pool remains out of scope.
|
|
592
598
|
|
|
593
599
|
**Genuinely open (decide before v1.1):**
|
|
594
|
-
- Multi-tab/multi-session concurrency (single global Executor + single attached tab today): does an agent need N tabs driven simultaneously? That breaks the singleton and requires per-cmd `tabId` everywhere on the wire.
|
|
595
600
|
- Web Store path: requires a `chrome.scripting`-only mode (no `chrome.debugger`) — a second executor backend behind the same interface.
|
|
596
601
|
- Whether `download` should ever use the extension `chrome.downloads` path (user Downloads dir) as an explicit opt-in, or remain server-fetch-only forever.
|