@mehmoodqureshi/chrome-mcp 0.4.1 → 0.4.2
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/dist/shared/policy.d.ts +34 -0
- package/dist/shared/policy.js +151 -0
- package/dist/shared/protocol.d.ts +15 -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/security/policy.d.ts +16 -34
- package/dist/src/security/policy.js +20 -124
- package/docs/BLUEPRINT.md +2 -2
- package/extension-dist/background.js +111 -0
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/policy.ts — the PURE policy decision, imported VERBATIM by both the
|
|
3
|
+
* server (src/security/policy.ts wraps it to throw ExecutorError) and the
|
|
4
|
+
* extension (router mirrors it to throw CmdError). Keeping the decision in one
|
|
5
|
+
* shared module is what makes the two ends provably agree — the gate is then
|
|
6
|
+
* genuinely enforced "at both ends" (server dispatch AND extension router).
|
|
7
|
+
*
|
|
8
|
+
* No throwing, no Node/Chrome deps: returns a verdict the caller renders into
|
|
9
|
+
* whatever error type it uses.
|
|
10
|
+
*/
|
|
11
|
+
import type { WireMethod, WirePolicy } from './protocol';
|
|
12
|
+
export declare function isReadMethod(method: WireMethod): boolean;
|
|
13
|
+
/** Everything in the mutating tool set that safe-mode disables. `eval` is gated
|
|
14
|
+
* separately via `allowEval`; `download_file` separately via `allowDownloads`. */
|
|
15
|
+
export declare function isMutatingMethod(method: WireMethod): boolean;
|
|
16
|
+
/** Whether the method touches a specific URL that must be allowlisted. */
|
|
17
|
+
export declare function isUrlGated(method: WireMethod): boolean;
|
|
18
|
+
/** Parse a host out of a URL string; '' if it has none (about:blank, data:, …). */
|
|
19
|
+
export declare function hostOf(url: string): string;
|
|
20
|
+
export declare function isDomainAllowed(url: string, policy: WirePolicy): boolean;
|
|
21
|
+
export type PolicyVerdict = {
|
|
22
|
+
ok: true;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether `method` against `url` is permitted by `policy`. `url` is the
|
|
29
|
+
* DESTINATION for navigation, otherwise the current tab URL. Pure: returns a
|
|
30
|
+
* verdict; the caller throws its own error type on `{ ok: false }`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evaluatePolicy(url: string, method: WireMethod, policy: WirePolicy): PolicyVerdict;
|
|
33
|
+
/** A wire policy that allows nothing — the safe default when none was delivered. */
|
|
34
|
+
export declare const DENY_ALL_WIRE_POLICY: WirePolicy;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/policy.ts — the PURE policy decision, imported VERBATIM by both the
|
|
4
|
+
* server (src/security/policy.ts wraps it to throw ExecutorError) and the
|
|
5
|
+
* extension (router mirrors it to throw CmdError). Keeping the decision in one
|
|
6
|
+
* shared module is what makes the two ends provably agree — the gate is then
|
|
7
|
+
* genuinely enforced "at both ends" (server dispatch AND extension router).
|
|
8
|
+
*
|
|
9
|
+
* No throwing, no Node/Chrome deps: returns a verdict the caller renders into
|
|
10
|
+
* whatever error type it uses.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.DENY_ALL_WIRE_POLICY = void 0;
|
|
14
|
+
exports.isReadMethod = isReadMethod;
|
|
15
|
+
exports.isMutatingMethod = isMutatingMethod;
|
|
16
|
+
exports.isUrlGated = isUrlGated;
|
|
17
|
+
exports.hostOf = hostOf;
|
|
18
|
+
exports.isDomainAllowed = isDomainAllowed;
|
|
19
|
+
exports.evaluatePolicy = evaluatePolicy;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Method classification
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Methods that read page CONTENT (the exfil payload) — URL-gated. */
|
|
24
|
+
const READ_CONTENT = new Set([
|
|
25
|
+
'get_text',
|
|
26
|
+
'get_html',
|
|
27
|
+
'screenshot',
|
|
28
|
+
'wait_for',
|
|
29
|
+
]);
|
|
30
|
+
/** Content-mutating actions — URL-gated AND mutation-gated. */
|
|
31
|
+
const MUTATE_CONTENT = new Set([
|
|
32
|
+
'click',
|
|
33
|
+
'type',
|
|
34
|
+
'press',
|
|
35
|
+
'hover',
|
|
36
|
+
'scroll',
|
|
37
|
+
]);
|
|
38
|
+
/** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
|
|
39
|
+
const NAVIGATION = new Set([
|
|
40
|
+
'navigate',
|
|
41
|
+
'back',
|
|
42
|
+
'forward',
|
|
43
|
+
'reload',
|
|
44
|
+
]);
|
|
45
|
+
/** Tab management — mutation-gated, but not content-URL-gated. */
|
|
46
|
+
const TAB_MUTATE = new Set([
|
|
47
|
+
'tab_select',
|
|
48
|
+
'tab_new',
|
|
49
|
+
'tab_close',
|
|
50
|
+
]);
|
|
51
|
+
function isReadMethod(method) {
|
|
52
|
+
return READ_CONTENT.has(method) || method === 'tabs_list';
|
|
53
|
+
}
|
|
54
|
+
/** Everything in the mutating tool set that safe-mode disables. `eval` is gated
|
|
55
|
+
* separately via `allowEval`; `download_file` separately via `allowDownloads`. */
|
|
56
|
+
function isMutatingMethod(method) {
|
|
57
|
+
return MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || TAB_MUTATE.has(method);
|
|
58
|
+
}
|
|
59
|
+
/** Whether the method touches a specific URL that must be allowlisted. */
|
|
60
|
+
function isUrlGated(method) {
|
|
61
|
+
return (READ_CONTENT.has(method) ||
|
|
62
|
+
MUTATE_CONTENT.has(method) ||
|
|
63
|
+
NAVIGATION.has(method) ||
|
|
64
|
+
method === 'eval' ||
|
|
65
|
+
method === 'upload_file');
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Domain matching
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** Parse a host out of a URL string; '' if it has none (about:blank, data:, …). */
|
|
71
|
+
function hostOf(url) {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(url).hostname.toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isAboutBlank(url) {
|
|
80
|
+
return url === 'about:blank' || url === '' || url.startsWith('about:');
|
|
81
|
+
}
|
|
82
|
+
/** Convert a single domain glob to a predicate. '*' matches everything;
|
|
83
|
+
* '*.example.com' matches example.com and any subdomain; otherwise exact host. */
|
|
84
|
+
function globMatches(host, pattern) {
|
|
85
|
+
const p = pattern.trim().toLowerCase();
|
|
86
|
+
if (p === '*' || p === '*://*/*')
|
|
87
|
+
return true;
|
|
88
|
+
if (p.startsWith('*.')) {
|
|
89
|
+
const base = p.slice(2);
|
|
90
|
+
return host === base || host.endsWith('.' + base);
|
|
91
|
+
}
|
|
92
|
+
return host === p;
|
|
93
|
+
}
|
|
94
|
+
function isDomainAllowed(url, policy) {
|
|
95
|
+
const host = hostOf(url);
|
|
96
|
+
if (!host)
|
|
97
|
+
return false;
|
|
98
|
+
return policy.allowDomains.some((pat) => globMatches(host, pat));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether `method` against `url` is permitted by `policy`. `url` is the
|
|
102
|
+
* DESTINATION for navigation, otherwise the current tab URL. Pure: returns a
|
|
103
|
+
* verdict; the caller throws its own error type on `{ ok: false }`.
|
|
104
|
+
*/
|
|
105
|
+
function evaluatePolicy(url, method, policy) {
|
|
106
|
+
// -- capability gates (independent of URL) --
|
|
107
|
+
if (method === 'eval' && !policy.allowEval) {
|
|
108
|
+
return { ok: false, reason: 'eval is disabled (safe-mode). Pass --unsafe-enable-eval to allow it.' };
|
|
109
|
+
}
|
|
110
|
+
if (method === 'download_file' && !policy.allowDownloads) {
|
|
111
|
+
return { ok: false, reason: 'downloads are disabled. Pass --enable-downloads or set allowDownloads.' };
|
|
112
|
+
}
|
|
113
|
+
if (method === 'upload_file' && !policy.allowUploads) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: 'uploads are disabled (sending local files to a page is an exfiltration risk). ' +
|
|
117
|
+
'Pass --enable-uploads or set allowUploads.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (isMutatingMethod(method) && !policy.enableMutations) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Methods that don't carry a content URL pass once the gates above are clear.
|
|
127
|
+
if (!isUrlGated(method))
|
|
128
|
+
return { ok: true };
|
|
129
|
+
// Navigating to a blank page is always fine.
|
|
130
|
+
if (isAboutBlank(url) && NAVIGATION.has(method))
|
|
131
|
+
return { ok: true };
|
|
132
|
+
if (!isDomainAllowed(url, policy)) {
|
|
133
|
+
const host = hostOf(url) || url;
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: `"${method}" denied: ${host} is not in the domain allowlist. ` +
|
|
137
|
+
`Add it to allowDomains, or pass --unsafe-all-domains.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
/** A wire policy that allows nothing — the safe default when none was delivered. */
|
|
143
|
+
exports.DENY_ALL_WIRE_POLICY = {
|
|
144
|
+
allowDomains: [],
|
|
145
|
+
allowEval: false,
|
|
146
|
+
allowDownloads: false,
|
|
147
|
+
allowUploads: false,
|
|
148
|
+
allowAllTabs: false,
|
|
149
|
+
enableMutations: false,
|
|
150
|
+
};
|
|
151
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -49,11 +49,26 @@ export interface HelloFrame extends BaseFrame {
|
|
|
49
49
|
chrome: string;
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The wire-serializable subset of the server's policy, delivered in `welcome` so
|
|
54
|
+
* the extension can enforce the SAME gate the server does (defense-in-depth). The
|
|
55
|
+
* server-only `uploadsDir` (a local filesystem path) is intentionally NOT sent.
|
|
56
|
+
*/
|
|
57
|
+
export interface WirePolicy {
|
|
58
|
+
allowDomains: string[];
|
|
59
|
+
allowEval: boolean;
|
|
60
|
+
allowDownloads: boolean;
|
|
61
|
+
allowUploads: boolean;
|
|
62
|
+
allowAllTabs: boolean;
|
|
63
|
+
enableMutations: boolean;
|
|
64
|
+
}
|
|
52
65
|
export interface WelcomeFrame extends BaseFrame {
|
|
53
66
|
type: 'welcome';
|
|
54
67
|
serverVersion: string;
|
|
55
68
|
sessionId: string;
|
|
56
69
|
heartbeatMs: number;
|
|
70
|
+
/** The active policy, so the extension can mirror the server-side gate. */
|
|
71
|
+
policy: WirePolicy;
|
|
57
72
|
}
|
|
58
73
|
export interface UnauthFrame extends BaseFrame {
|
|
59
74
|
type: 'unauthorized';
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 3. On success, send `welcome`, promote to the single ACTIVE connection
|
|
11
11
|
* (superseding any prior one — a security-relevant displacement event).
|
|
12
12
|
*/
|
|
13
|
-
import { type WireEvent, type WireMethod } from '../../shared/protocol';
|
|
13
|
+
import { type WireEvent, type WireMethod, type WirePolicy } from '../../shared/protocol';
|
|
14
14
|
export interface DisplacementInfo {
|
|
15
15
|
oldExtId: string;
|
|
16
16
|
newExtId: string;
|
|
@@ -20,6 +20,9 @@ export interface DisplacementInfo {
|
|
|
20
20
|
export interface BridgeOptions {
|
|
21
21
|
token: string;
|
|
22
22
|
serverVersion: string;
|
|
23
|
+
/** Active policy, sent to the extension in `welcome` so it mirrors the gate.
|
|
24
|
+
* Defaults to deny-all if omitted. */
|
|
25
|
+
policy?: WirePolicy;
|
|
23
26
|
port?: number;
|
|
24
27
|
host?: string;
|
|
25
28
|
heartbeatMs?: number;
|
|
@@ -16,6 +16,7 @@ exports.BridgeServer = void 0;
|
|
|
16
16
|
const ws_1 = require("ws");
|
|
17
17
|
const node_crypto_1 = require("node:crypto");
|
|
18
18
|
const protocol_1 = require("../../shared/protocol");
|
|
19
|
+
const policy_1 = require("../../shared/policy");
|
|
19
20
|
const types_1 = require("../executor/types");
|
|
20
21
|
const connection_1 = require("./connection");
|
|
21
22
|
const auth_1 = require("./auth");
|
|
@@ -174,6 +175,7 @@ class BridgeServer {
|
|
|
174
175
|
serverVersion: this.opts.serverVersion,
|
|
175
176
|
sessionId,
|
|
176
177
|
heartbeatMs: this.heartbeatMs,
|
|
178
|
+
policy: this.opts.policy ?? policy_1.DENY_ALL_WIRE_POLICY,
|
|
177
179
|
};
|
|
178
180
|
this.send(ws, welcome);
|
|
179
181
|
this.log(`extension paired (session ${sessionId}, id "${ext.id}")`);
|
package/dist/src/cli.js
CHANGED
|
@@ -61,9 +61,12 @@ async function main() {
|
|
|
61
61
|
}
|
|
62
62
|
const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
|
|
63
63
|
const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
|
|
64
|
+
const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
|
|
64
65
|
const bridge = new server_1.BridgeServer({
|
|
65
66
|
token,
|
|
66
67
|
serverVersion: version(),
|
|
68
|
+
// Wire-serializable policy subset (no local uploadsDir) so the extension mirrors the gate.
|
|
69
|
+
policy: { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations },
|
|
67
70
|
port: cfg.wsPort,
|
|
68
71
|
onLog: (m) => (0, server_2.logErr)(m),
|
|
69
72
|
onDisplacement: (d) => (0, server_2.logErr)(`SECURITY: extension connection displaced (different id: ${d.differentId})`),
|
|
@@ -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]
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
}
|
|
76
76
|
switch (frame.type) {
|
|
77
77
|
case "welcome":
|
|
78
|
+
this.deps.onPolicy(frame.policy);
|
|
78
79
|
this.setState("connected");
|
|
79
80
|
this.deps.log("paired with server");
|
|
80
81
|
break;
|
|
@@ -116,6 +117,92 @@
|
|
|
116
117
|
}
|
|
117
118
|
};
|
|
118
119
|
|
|
120
|
+
// shared/policy.ts
|
|
121
|
+
var READ_CONTENT = /* @__PURE__ */ new Set([
|
|
122
|
+
"get_text",
|
|
123
|
+
"get_html",
|
|
124
|
+
"screenshot",
|
|
125
|
+
"wait_for"
|
|
126
|
+
]);
|
|
127
|
+
var MUTATE_CONTENT = /* @__PURE__ */ new Set([
|
|
128
|
+
"click",
|
|
129
|
+
"type",
|
|
130
|
+
"press",
|
|
131
|
+
"hover",
|
|
132
|
+
"scroll"
|
|
133
|
+
]);
|
|
134
|
+
var NAVIGATION = /* @__PURE__ */ new Set([
|
|
135
|
+
"navigate",
|
|
136
|
+
"back",
|
|
137
|
+
"forward",
|
|
138
|
+
"reload"
|
|
139
|
+
]);
|
|
140
|
+
var TAB_MUTATE = /* @__PURE__ */ new Set([
|
|
141
|
+
"tab_select",
|
|
142
|
+
"tab_new",
|
|
143
|
+
"tab_close"
|
|
144
|
+
]);
|
|
145
|
+
function isMutatingMethod(method) {
|
|
146
|
+
return MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || TAB_MUTATE.has(method);
|
|
147
|
+
}
|
|
148
|
+
function isUrlGated(method) {
|
|
149
|
+
return READ_CONTENT.has(method) || MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || method === "eval" || method === "upload_file";
|
|
150
|
+
}
|
|
151
|
+
function hostOf(url) {
|
|
152
|
+
try {
|
|
153
|
+
return new URL(url).hostname.toLowerCase();
|
|
154
|
+
} catch {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function isAboutBlank(url) {
|
|
159
|
+
return url === "about:blank" || url === "" || url.startsWith("about:");
|
|
160
|
+
}
|
|
161
|
+
function globMatches(host, pattern) {
|
|
162
|
+
const p = pattern.trim().toLowerCase();
|
|
163
|
+
if (p === "*" || p === "*://*/*") return true;
|
|
164
|
+
if (p.startsWith("*.")) {
|
|
165
|
+
const base = p.slice(2);
|
|
166
|
+
return host === base || host.endsWith("." + base);
|
|
167
|
+
}
|
|
168
|
+
return host === p;
|
|
169
|
+
}
|
|
170
|
+
function isDomainAllowed(url, policy) {
|
|
171
|
+
const host = hostOf(url);
|
|
172
|
+
if (!host) return false;
|
|
173
|
+
return policy.allowDomains.some((pat) => globMatches(host, pat));
|
|
174
|
+
}
|
|
175
|
+
function evaluatePolicy(url, method, policy) {
|
|
176
|
+
if (method === "eval" && !policy.allowEval) {
|
|
177
|
+
return { ok: false, reason: "eval is disabled (safe-mode). Pass --unsafe-enable-eval to allow it." };
|
|
178
|
+
}
|
|
179
|
+
if (method === "download_file" && !policy.allowDownloads) {
|
|
180
|
+
return { ok: false, reason: "downloads are disabled. Pass --enable-downloads or set allowDownloads." };
|
|
181
|
+
}
|
|
182
|
+
if (method === "upload_file" && !policy.allowUploads) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: "uploads are disabled (sending local files to a page is an exfiltration risk). Pass --enable-uploads or set allowUploads."
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (isMutatingMethod(method) && !policy.enableMutations) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (!isUrlGated(method)) return { ok: true };
|
|
195
|
+
if (isAboutBlank(url) && NAVIGATION.has(method)) return { ok: true };
|
|
196
|
+
if (!isDomainAllowed(url, policy)) {
|
|
197
|
+
const host = hostOf(url) || url;
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: `"${method}" denied: ${host} is not in the domain allowlist. Add it to allowDomains, or pass --unsafe-all-domains.`
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
119
206
|
// shared/download.ts
|
|
120
207
|
var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
121
208
|
var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
@@ -303,6 +390,19 @@
|
|
|
303
390
|
async function targetTab(cmd) {
|
|
304
391
|
return cmd.tabId ? parseTabId(cmd.tabId) : currentTabId();
|
|
305
392
|
}
|
|
393
|
+
async function urlForCommand(cmd) {
|
|
394
|
+
if (cmd.method === "navigate") {
|
|
395
|
+
const u = cmd.params.url;
|
|
396
|
+
return typeof u === "string" ? u : "";
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const tabId = await targetTab(cmd);
|
|
400
|
+
const t = await chrome.tabs.get(tabId);
|
|
401
|
+
return t.url ?? "";
|
|
402
|
+
} catch {
|
|
403
|
+
return "";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
306
406
|
async function execInTab(tabId, func, args = [], world) {
|
|
307
407
|
const [res] = await chrome.scripting.executeScript({
|
|
308
408
|
target: { tabId },
|
|
@@ -829,6 +929,12 @@
|
|
|
829
929
|
}
|
|
830
930
|
async dispatch(cmd) {
|
|
831
931
|
try {
|
|
932
|
+
const policy = this.deps.getPolicy();
|
|
933
|
+
if (policy) {
|
|
934
|
+
const url = isUrlGated(cmd.method) ? await urlForCommand(cmd) : "";
|
|
935
|
+
const verdict = evaluatePolicy(url, cmd.method, policy);
|
|
936
|
+
if (!verdict.ok) throw new CmdError("POLICY_DENIED", verdict.reason);
|
|
937
|
+
}
|
|
832
938
|
const data = await this.deps.exec.run(cmd);
|
|
833
939
|
const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
|
|
834
940
|
this.deps.send(frame);
|
|
@@ -850,15 +956,20 @@
|
|
|
850
956
|
|
|
851
957
|
// extension/src/sw/background.ts
|
|
852
958
|
var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
|
|
959
|
+
var currentPolicy = null;
|
|
853
960
|
var executor = new ChromeExecutor();
|
|
854
961
|
var ws = new WsClient({
|
|
855
962
|
onCommand: (cmd) => void router.dispatch(cmd),
|
|
856
963
|
onState: (state) => void persistState(state),
|
|
964
|
+
onPolicy: (policy) => {
|
|
965
|
+
currentPolicy = policy;
|
|
966
|
+
},
|
|
857
967
|
log: (m) => console.debug("[chrome-mcp]", m)
|
|
858
968
|
});
|
|
859
969
|
var router = new CommandRouter({
|
|
860
970
|
exec: executor,
|
|
861
971
|
send: (frame) => ws.send(frame),
|
|
972
|
+
getPolicy: () => currentPolicy,
|
|
862
973
|
log: (m) => console.debug("[chrome-mcp]", m)
|
|
863
974
|
});
|
|
864
975
|
async function getConfig() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome MCP Bridge",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.2",
|
|
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
7
|
"background": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|