@mehmoodqureshi/chrome-mcp 0.6.7 → 0.7.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.
Files changed (36) hide show
  1. package/README.md +116 -0
  2. package/dist/shared/observers.d.ts +110 -0
  3. package/dist/shared/observers.js +127 -0
  4. package/dist/shared/page-fns.d.ts +46 -0
  5. package/dist/shared/page-fns.js +292 -0
  6. package/dist/shared/policy.d.ts +9 -0
  7. package/dist/shared/policy.js +16 -0
  8. package/dist/shared/protocol.d.ts +11 -2
  9. package/dist/shared/protocol.js +3 -0
  10. package/dist/shared/snapshot.d.ts +2 -0
  11. package/dist/shared/snapshot.js +8 -1
  12. package/dist/src/bridge/workspace.d.ts +6 -0
  13. package/dist/src/bridge/workspace.js +20 -0
  14. package/dist/src/config.js +31 -0
  15. package/dist/src/executor/extension-executor.d.ts +27 -13
  16. package/dist/src/executor/extension-executor.js +53 -12
  17. package/dist/src/executor/stub-executor.d.ts +19 -1
  18. package/dist/src/executor/stub-executor.js +22 -6
  19. package/dist/src/executor/types.d.ts +72 -13
  20. package/dist/src/mcp/audit.d.ts +39 -0
  21. package/dist/src/mcp/audit.js +60 -0
  22. package/dist/src/mcp/helpers.d.ts +4 -4
  23. package/dist/src/mcp/helpers.js +9 -4
  24. package/dist/src/mcp/locate.d.ts +45 -0
  25. package/dist/src/mcp/locate.js +105 -0
  26. package/dist/src/mcp/redact.d.ts +48 -0
  27. package/dist/src/mcp/redact.js +92 -0
  28. package/dist/src/mcp/snapdiff.d.ts +43 -0
  29. package/dist/src/mcp/snapdiff.js +92 -0
  30. package/dist/src/mcp/tools.js +429 -43
  31. package/dist/src/security/policy.d.ts +5 -0
  32. package/dist/src/security/policy.js +6 -0
  33. package/docs/BLUEPRINT.md +15 -1
  34. package/extension-dist/background.js +617 -214
  35. package/extension-dist/page-hook.js +215 -0
  36. package/package.json +1 -1
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * src/mcp/locate.ts — target an element by what it IS rather than by where it
4
+ * sits in the DOM.
5
+ *
6
+ * Today every action needs a CSS selector or a `ref` from a snapshot, so the
7
+ * cheapest way to click "Sign in" is to pull the whole accessibility tree first
8
+ * and read a ref out of it. That is a large read to perform one small action,
9
+ * and a hand-written selector is the alternative that breaks on the next
10
+ * redeploy.
11
+ *
12
+ * A locator closes that: `{ role: 'button', name: 'Sign in' }` resolves through
13
+ * one snapshot, server-side, and the caller never sees the tree. Matching runs
14
+ * strongest-first (exact, then case-insensitive, then contains) so an
15
+ * unambiguous name wins outright, and an ambiguous one fails loudly with the
16
+ * candidates rather than silently clicking the first row.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.hasLocator = hasLocator;
20
+ exports.resolveLocator = resolveLocator;
21
+ const validators_1 = require("./validators");
22
+ /** Was a locator supplied at all? */
23
+ function hasLocator(l) {
24
+ return !!l && (l.role !== undefined || l.name !== undefined || l.text !== undefined);
25
+ }
26
+ const norm = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
27
+ /**
28
+ * Rank a node against the locator. Higher is better; 0 means no match.
29
+ * The tiers are what make an exact name beat a substring of a longer label.
30
+ */
31
+ function score(node, want) {
32
+ if (want.role && norm(node.role) !== norm(want.role))
33
+ return 0;
34
+ if (!want.name)
35
+ return 1; // role-only locator: any node of that role
36
+ const have = norm(node.name);
37
+ const need = norm(want.name);
38
+ if (!have)
39
+ return 0;
40
+ if (node.name.trim() === want.name.trim())
41
+ return 4; // exact, case-sensitive
42
+ if (have === need)
43
+ return 3; // exact, case-insensitive
44
+ if (have.startsWith(need))
45
+ return 2;
46
+ if (have.includes(need))
47
+ return 1;
48
+ return 0;
49
+ }
50
+ /**
51
+ * Resolve a locator to a `ref` by taking one snapshot of the target tab.
52
+ *
53
+ * Throws `McpToolError` when nothing matches or when the best tier is ambiguous
54
+ * — an ambiguous click is a wrong click, and the message lists what it found so
55
+ * the caller can narrow it (or pass `nth`).
56
+ */
57
+ async function resolveLocator(ex, loc, opts = {}) {
58
+ const want = { role: loc.role, name: loc.name ?? loc.text };
59
+ const snap = await ex.snapshot({
60
+ tabId: opts.tabId,
61
+ interactiveOnly: false,
62
+ max: 400,
63
+ frameId: opts.frameId,
64
+ allFrames: opts.allFrames,
65
+ });
66
+ let best = 0;
67
+ let winners = [];
68
+ for (const node of snap.nodes) {
69
+ const s = score(node, want);
70
+ if (s === 0)
71
+ continue;
72
+ if (s > best) {
73
+ best = s;
74
+ winners = [node];
75
+ }
76
+ else if (s === best) {
77
+ winners.push(node);
78
+ }
79
+ }
80
+ const describe = (n) => `${n.role} "${n.name}"`;
81
+ if (winners.length === 0) {
82
+ const sample = snap.nodes
83
+ .filter((n) => !want.role || norm(n.role) === norm(want.role))
84
+ .slice(0, 8)
85
+ .map(describe);
86
+ throw new validators_1.McpToolError(`no element matches ${JSON.stringify(want)}. ` +
87
+ (sample.length
88
+ ? `Closest by role: ${sample.join(', ')}. `
89
+ : 'Nothing on the page has that role. ') +
90
+ 'Take a `snapshot` to see what is there, or target by `selector` instead.');
91
+ }
92
+ if (loc.nth !== undefined) {
93
+ const picked = winners[loc.nth];
94
+ if (!picked) {
95
+ throw new validators_1.McpToolError(`nth=${loc.nth} is out of range: ${winners.length} element(s) match ${JSON.stringify(want)}`);
96
+ }
97
+ return { target: { ref: picked.ref }, node: picked, matches: winners.length };
98
+ }
99
+ if (winners.length > 1) {
100
+ throw new validators_1.McpToolError(`${winners.length} elements match ${JSON.stringify(want)}: ${winners.slice(0, 6).map(describe).join(', ')}. ` +
101
+ 'Narrow the name, add a role, or pass `nth` to choose one.');
102
+ }
103
+ return { target: { ref: winners[0].ref }, node: winners[0], matches: 1 };
104
+ }
105
+ //# sourceMappingURL=locate.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * src/mcp/redact.ts — keep secrets that happen to be on the page out of the
3
+ * model's context.
4
+ *
5
+ * The premise of this tool is that it drives a browser you are already logged
6
+ * into. That is also the problem: `get_text` / `get_html` / `read_as_markdown` /
7
+ * `eval` return whatever is on the page, and on a logged-in page that routinely
8
+ * includes a session token rendered into a script tag, an API key on a settings
9
+ * screen, or the value sitting in a password field. The domain allowlist decides
10
+ * WHICH pages may be read; it has nothing to say about what comes back from one
11
+ * that is allowed.
12
+ *
13
+ * Two layers, deliberately different in strength:
14
+ * - password-field values are ALWAYS suppressed. That one is unambiguous — no
15
+ * caller ever wants the characters in a `<input type=password>` — so it
16
+ * needs no flag and has no false positives.
17
+ * - pattern redaction (JWTs, cloud keys, bearer tokens, private key blocks) is
18
+ * opt-in via `--redact`, because a pattern can and will fire on something a
19
+ * user legitimately asked to read.
20
+ */
21
+ export interface RedactionConfig {
22
+ /** Pattern-based redaction (the opt-in layer). Password fields are handled regardless. */
23
+ enabled: boolean;
24
+ /** Extra caller-supplied patterns, already compiled. */
25
+ extra: RegExp[];
26
+ }
27
+ export declare const NO_REDACTION: RedactionConfig;
28
+ /**
29
+ * Compile a user-supplied pattern. Invalid regexes are a configuration error
30
+ * worth failing loudly on — silently ignoring one would leave the user believing
31
+ * a secret is being scrubbed when it is not.
32
+ */
33
+ export declare function compileRedactionPattern(source: string): RegExp;
34
+ export interface Redacted<T> {
35
+ value: T;
36
+ /** How many substitutions were made, so a caller can see redaction happened. */
37
+ redactions: number;
38
+ }
39
+ /** Replace every match of the configured patterns with a labelled marker. */
40
+ export declare function redactText(text: string, cfg: RedactionConfig): Redacted<string>;
41
+ /**
42
+ * Strip the `value` of every password input, then apply pattern redaction.
43
+ *
44
+ * The value attribute is emptied rather than removed so the markup keeps its
45
+ * shape — a caller reasoning about the form still sees the field, just not
46
+ * what is in it.
47
+ */
48
+ export declare function redactHtml(html: string, cfg: RedactionConfig): Redacted<string>;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * src/mcp/redact.ts — keep secrets that happen to be on the page out of the
4
+ * model's context.
5
+ *
6
+ * The premise of this tool is that it drives a browser you are already logged
7
+ * into. That is also the problem: `get_text` / `get_html` / `read_as_markdown` /
8
+ * `eval` return whatever is on the page, and on a logged-in page that routinely
9
+ * includes a session token rendered into a script tag, an API key on a settings
10
+ * screen, or the value sitting in a password field. The domain allowlist decides
11
+ * WHICH pages may be read; it has nothing to say about what comes back from one
12
+ * that is allowed.
13
+ *
14
+ * Two layers, deliberately different in strength:
15
+ * - password-field values are ALWAYS suppressed. That one is unambiguous — no
16
+ * caller ever wants the characters in a `<input type=password>` — so it
17
+ * needs no flag and has no false positives.
18
+ * - pattern redaction (JWTs, cloud keys, bearer tokens, private key blocks) is
19
+ * opt-in via `--redact`, because a pattern can and will fire on something a
20
+ * user legitimately asked to read.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.NO_REDACTION = void 0;
24
+ exports.compileRedactionPattern = compileRedactionPattern;
25
+ exports.redactText = redactText;
26
+ exports.redactHtml = redactHtml;
27
+ exports.NO_REDACTION = { enabled: false, extra: [] };
28
+ /** Well-known secret shapes. Each is anchored enough not to fire on prose. */
29
+ const BUILTIN = [
30
+ { kind: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,8000}?-----END [A-Z ]*PRIVATE KEY-----/g },
31
+ { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
32
+ { kind: 'aws-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
33
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
34
+ { kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
35
+ { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
36
+ { kind: 'api-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
37
+ { kind: 'bearer', re: /\bBearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/g },
38
+ ];
39
+ /**
40
+ * Compile a user-supplied pattern. Invalid regexes are a configuration error
41
+ * worth failing loudly on — silently ignoring one would leave the user believing
42
+ * a secret is being scrubbed when it is not.
43
+ */
44
+ function compileRedactionPattern(source) {
45
+ return new RegExp(source, 'g');
46
+ }
47
+ /** Replace every match of the configured patterns with a labelled marker. */
48
+ function redactText(text, cfg) {
49
+ if (!cfg.enabled || !text)
50
+ return { value: text, redactions: 0 };
51
+ let out = text;
52
+ let count = 0;
53
+ for (const { kind, re } of BUILTIN) {
54
+ out = out.replace(new RegExp(re.source, re.flags), () => {
55
+ count++;
56
+ return `[redacted:${kind}]`;
57
+ });
58
+ }
59
+ for (const re of cfg.extra) {
60
+ out = out.replace(new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`), () => {
61
+ count++;
62
+ return '[redacted:custom]';
63
+ });
64
+ }
65
+ return { value: out, redactions: count };
66
+ }
67
+ /** Matches one complete `<input …>` tag so its attributes can be inspected. */
68
+ const INPUT_TAG = /<input\b[^>]*>/gi;
69
+ const VALUE_ATTR = /\bvalue\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/i;
70
+ const PASSWORD_TYPE = /\btype\s*=\s*["']?password["']?/i;
71
+ /**
72
+ * Strip the `value` of every password input, then apply pattern redaction.
73
+ *
74
+ * The value attribute is emptied rather than removed so the markup keeps its
75
+ * shape — a caller reasoning about the form still sees the field, just not
76
+ * what is in it.
77
+ */
78
+ function redactHtml(html, cfg) {
79
+ if (!html)
80
+ return { value: html, redactions: 0 };
81
+ let count = 0;
82
+ let out = html.replace(INPUT_TAG, (tag) => {
83
+ if (!PASSWORD_TYPE.test(tag) || !VALUE_ATTR.test(tag))
84
+ return tag;
85
+ count++;
86
+ return tag.replace(VALUE_ATTR, 'value="[redacted:password]"');
87
+ });
88
+ const patterned = redactText(out, cfg);
89
+ out = patterned.value;
90
+ return { value: out, redactions: count + patterned.redactions };
91
+ }
92
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * src/mcp/snapdiff.ts — remember the last accessibility snapshot per tab and
3
+ * answer "what changed" instead of re-sending the page.
4
+ *
5
+ * A snapshot is the most token-expensive read in the tool surface, and the agent
6
+ * loop that leans on it hardest — snapshot, click, snapshot, click — re-sends a
7
+ * page that is mostly identical every time. The diff is what the caller actually
8
+ * wanted: this button appeared, that error text is new, submit is no longer
9
+ * disabled.
10
+ *
11
+ * Refs (`e1`, `e2`, ...) are minted in document order on every snapshot, so they
12
+ * are NOT identity across snapshots: `e7` is a different element the moment
13
+ * anything above it is inserted. Nodes are therefore matched on role + tag +
14
+ * accessible name, and every diff entry carries the CURRENT ref, so anything the
15
+ * caller is told about is immediately targetable.
16
+ */
17
+ import type { SnapshotNode, SnapshotResult } from '../executor/types';
18
+ export interface StoredSnapshot {
19
+ id: string;
20
+ ts: number;
21
+ url: string;
22
+ nodes: SnapshotNode[];
23
+ }
24
+ export interface SnapshotDiff {
25
+ /** The snapshot this was compared against, or null when there was none. */
26
+ since: string | null;
27
+ added: SnapshotNode[];
28
+ removed: SnapshotNode[];
29
+ changed: Array<{
30
+ node: SnapshotNode;
31
+ was: Partial<SnapshotNode>;
32
+ }>;
33
+ unchanged: number;
34
+ }
35
+ /** Identity of a node ACROSS snapshots - deliberately not the ref. */
36
+ export declare function nodeKey(n: SnapshotNode): string;
37
+ /** Scope key: snapshots are per browser profile and per tab. */
38
+ export declare function scopeOf(profile: string, tabId?: string): string;
39
+ export declare function rememberSnapshot(scope: string, snap: SnapshotResult): StoredSnapshot;
40
+ export declare function lastSnapshot(scope: string): StoredSnapshot | undefined;
41
+ /** Forget everything - for tests, and whenever the active profile changes. */
42
+ export declare function resetSnapshots(): void;
43
+ export declare function diffSnapshots(prev: StoredSnapshot | undefined, next: SnapshotResult): SnapshotDiff;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * src/mcp/snapdiff.ts — remember the last accessibility snapshot per tab and
4
+ * answer "what changed" instead of re-sending the page.
5
+ *
6
+ * A snapshot is the most token-expensive read in the tool surface, and the agent
7
+ * loop that leans on it hardest — snapshot, click, snapshot, click — re-sends a
8
+ * page that is mostly identical every time. The diff is what the caller actually
9
+ * wanted: this button appeared, that error text is new, submit is no longer
10
+ * disabled.
11
+ *
12
+ * Refs (`e1`, `e2`, ...) are minted in document order on every snapshot, so they
13
+ * are NOT identity across snapshots: `e7` is a different element the moment
14
+ * anything above it is inserted. Nodes are therefore matched on role + tag +
15
+ * accessible name, and every diff entry carries the CURRENT ref, so anything the
16
+ * caller is told about is immediately targetable.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.nodeKey = nodeKey;
20
+ exports.scopeOf = scopeOf;
21
+ exports.rememberSnapshot = rememberSnapshot;
22
+ exports.lastSnapshot = lastSnapshot;
23
+ exports.resetSnapshots = resetSnapshots;
24
+ exports.diffSnapshots = diffSnapshots;
25
+ /** How many tabs' snapshots to keep. Small: a convenience cache, not state. */
26
+ const MAX_SCOPES = 32;
27
+ const store = new Map();
28
+ let counter = 0;
29
+ /** Identity of a node ACROSS snapshots - deliberately not the ref. */
30
+ function nodeKey(n) {
31
+ return `${n.role} | ${n.tag} | ${n.name}`;
32
+ }
33
+ /** Scope key: snapshots are per browser profile and per tab. */
34
+ function scopeOf(profile, tabId) {
35
+ return `${profile} | ${tabId ?? 'active'}`;
36
+ }
37
+ function rememberSnapshot(scope, snap) {
38
+ const stored = { id: `snap_${++counter}`, ts: Date.now(), url: snap.url, nodes: snap.nodes };
39
+ store.set(scope, stored);
40
+ // Bounded: drop the oldest insertion once over the ceiling.
41
+ while (store.size > MAX_SCOPES) {
42
+ const oldest = store.keys().next();
43
+ if (oldest.done)
44
+ break;
45
+ store.delete(oldest.value);
46
+ }
47
+ return stored;
48
+ }
49
+ function lastSnapshot(scope) {
50
+ return store.get(scope);
51
+ }
52
+ /** Forget everything - for tests, and whenever the active profile changes. */
53
+ function resetSnapshots() {
54
+ store.clear();
55
+ counter = 0;
56
+ }
57
+ /** The state fields a diff reports as "changed" (the ref is expected to move). */
58
+ const STATE_FIELDS = ['value', 'disabled', 'checked'];
59
+ function diffSnapshots(prev, next) {
60
+ if (!prev) {
61
+ return { since: null, added: next.nodes, removed: [], changed: [], unchanged: 0 };
62
+ }
63
+ const before = new Map();
64
+ for (const n of prev.nodes)
65
+ if (!before.has(nodeKey(n)))
66
+ before.set(nodeKey(n), n);
67
+ const added = [];
68
+ const changed = [];
69
+ let unchanged = 0;
70
+ const matched = new Set();
71
+ for (const n of next.nodes) {
72
+ const key = nodeKey(n);
73
+ const old = before.get(key);
74
+ if (!old) {
75
+ added.push(n);
76
+ continue;
77
+ }
78
+ matched.add(key);
79
+ const was = {};
80
+ for (const f of STATE_FIELDS) {
81
+ if (old[f] !== n[f])
82
+ was[f] = old[f];
83
+ }
84
+ if (Object.keys(was).length > 0)
85
+ changed.push({ node: n, was });
86
+ else
87
+ unchanged++;
88
+ }
89
+ const removed = prev.nodes.filter((n) => !matched.has(nodeKey(n)));
90
+ return { since: prev.id, added, removed, changed, unchanged };
91
+ }
92
+ //# sourceMappingURL=snapdiff.js.map