@mindstone/mcp-server-browser-automation 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -16
- package/dist/browser-client.js +32 -11
- package/dist/path-safety.d.ts +108 -0
- package/dist/path-safety.js +311 -0
- package/dist/server.js +2 -1
- package/dist/tools/files.d.ts +3 -0
- package/dist/tools/files.js +44 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/interaction.js +23 -27
- package/dist/tools/navigation.js +2 -1
- package/dist/tools/observation.js +57 -4
- package/dist/tools/session.js +2 -1
- package/dist/untrusted-content.d.ts +33 -0
- package/dist/untrusted-content.js +66 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@ Browser control you can watch: open pages, sign in, click around, fill forms, ta
|
|
|
9
9
|
|
|
10
10
|
## Status
|
|
11
11
|
|
|
12
|
-
- **Version:** [0.1
|
|
12
|
+
- **Version:** [0.2.1](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-browser-automation)
|
|
13
13
|
- **Auth:** None ([`server.json`](./server.json))
|
|
14
|
-
- **Tools:** [
|
|
14
|
+
- **Tools:** [21](./src/tools/) (navigation, observation, interaction, sessions, files)
|
|
15
15
|
- **Surface:** browser-automation
|
|
16
16
|
- **Machine-readable:** [`STATUS.json`](./STATUS.json)
|
|
17
17
|
|
|
@@ -99,7 +99,7 @@ This server requires the `agent-browser` CLI binary to control the browser.
|
|
|
99
99
|
### Binary Resolution
|
|
100
100
|
|
|
101
101
|
1. **PATH lookup** (preferred): If `agent-browser` is on your PATH, it is used directly.
|
|
102
|
-
2. **npx fallback**: If the binary is not found, the server automatically falls back to `npx -y agent-browser@0.
|
|
102
|
+
2. **npx fallback**: If the binary is not found, the server automatically falls back to `npx -y agent-browser@0.33.2`.
|
|
103
103
|
|
|
104
104
|
### Installing agent-browser
|
|
105
105
|
|
|
@@ -117,7 +117,7 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
117
117
|
|---|---|---|
|
|
118
118
|
| `AGENT_BROWSER_SESSION_NAME` | No | Session name for browser persistence (default: `mcp`) |
|
|
119
119
|
| `AGENT_BROWSER_SHOW_WINDOW` | No | Set to `false` to run without a visible browser window. Default is visible (`true`). |
|
|
120
|
-
| `
|
|
120
|
+
| `MCP_WORKSPACE_PATH` | No | Workspace directory that `browser_pdf` writes into and `browser_upload` reads from. Defaults to the system temp directory. See [Security notes](#security-notes). |
|
|
121
121
|
|
|
122
122
|
### MCP Host Configuration
|
|
123
123
|
|
|
@@ -132,7 +132,7 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
132
132
|
}
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
## Available Tools (
|
|
135
|
+
## Available Tools (21)
|
|
136
136
|
|
|
137
137
|
### Navigation
|
|
138
138
|
- **browser_navigate** — Navigate to a URL
|
|
@@ -144,6 +144,8 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
144
144
|
- **browser_snapshot** — Get the page accessibility tree with interactive element references
|
|
145
145
|
- **browser_screenshot** — Take a screenshot of the current page
|
|
146
146
|
- **browser_get_page_info** — Get the current page URL and title
|
|
147
|
+
- **browser_get_text** — Get the text content of the page or a single element
|
|
148
|
+
- **browser_pdf** — Save the current page as a PDF inside the workspace directory (refuses to overwrite existing files unless `overwrite: true`)
|
|
147
149
|
|
|
148
150
|
### Interaction
|
|
149
151
|
- **browser_click** — Click an element using @ref or CSS selector
|
|
@@ -153,7 +155,8 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
153
155
|
- **browser_scroll** — Scroll the page in a direction
|
|
154
156
|
- **browser_select** — Select an option from a dropdown
|
|
155
157
|
- **browser_hover** — Hover over an element
|
|
156
|
-
- **
|
|
158
|
+
- **browser_upload** — Upload workspace files to a file input (regular files only, staged privately before upload)
|
|
159
|
+
- **browser_evaluate** — Execute JavaScript in the page context (on by default; `destructiveHint: true` so hosts can require confirmation — see [Security notes](#security-notes))
|
|
157
160
|
|
|
158
161
|
### Session Management
|
|
159
162
|
- **browser_tabs** — List open tabs or switch to a tab
|
|
@@ -173,15 +176,9 @@ The typical workflow uses accessibility snapshots for reliable element targeting
|
|
|
173
176
|
|
|
174
177
|
Browser automation has a large attack surface: the agent-browser CLI controls a real headless browser that loads URLs you pass it, runs page-side JavaScript, and persists cookies and session state across runs. Read this section before deploying.
|
|
175
178
|
|
|
176
|
-
### `browser_evaluate` is
|
|
179
|
+
### `browser_evaluate` runs by default — host confirmation is the gate
|
|
177
180
|
|
|
178
|
-
`browser_evaluate` lets the model execute arbitrary JavaScript inside the page context — the security equivalent of giving the model a shell on whatever site it has just navigated to.
|
|
179
|
-
|
|
180
|
-
```bash
|
|
181
|
-
BROWSER_AUTOMATION_ALLOW_EVAL=1 mcp-server-browser-automation
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Without this env var, `browser_evaluate` is **not** in the tools list at all — the LLM cannot even see it. When enabled, the tool is marked so MCP hosts can require explicit user confirmation before each invocation.
|
|
181
|
+
`browser_evaluate` lets the model execute arbitrary JavaScript inside the page context — the security equivalent of giving the model a shell on whatever site it has just navigated to. The tool is registered **unconditionally** (capability-first); the safeguard is the host's tool-approval layer. The tool is marked `destructiveHint: true`, so MCP hosts SHOULD require explicit user confirmation before each invocation. Do not configure a host to auto-approve this tool.
|
|
185
182
|
|
|
186
183
|
### URL scheme deny-list
|
|
187
184
|
|
|
@@ -203,9 +200,21 @@ To override the session name (for example, to keep separate profiles per project
|
|
|
203
200
|
### Recommended deployment posture
|
|
204
201
|
|
|
205
202
|
- **Run the connector against a separate browser profile** — a dedicated `AGENT_BROWSER_SESSION_NAME` per MCP host. Do not reuse your daily browser profile: the connector reads and overwrites cookies in whichever profile it is pointed at, and a malicious page can ride the existing session of any site you are logged into.
|
|
206
|
-
- **
|
|
203
|
+
- **Require host confirmation for `browser_evaluate`** — it runs by default; every call executes arbitrary JavaScript in the page context.
|
|
207
204
|
- **Require host confirmation** for `browser_authenticate` and any flow that may navigate to authenticated sites — otherwise prompt injection in fetched content can drive the browser at sites the user is logged into.
|
|
208
|
-
- **
|
|
205
|
+
- **Returned page content is enveloped as untrusted** — accessibility snapshots, page text, titles, URLs, tab lists, and `browser_evaluate` outputs come from arbitrary websites and may contain prompt-injection attempts. The connector wraps them in `<untrusted-content source="…">` envelopes (with close-tag breakout escaping) so hosts and models treat them as data, not instructions. Keep that treatment on your side: don't strip the envelopes, and don't let page text alone trigger irreversible actions.
|
|
206
|
+
|
|
207
|
+
### Workspace sandbox for file tools
|
|
208
|
+
|
|
209
|
+
`browser_pdf` writes PDF files and `browser_upload` reads files, and both are constrained to a workspace directory: `MCP_WORKSPACE_PATH` when set, otherwise the system temp directory. Paths are canonicalised before use, so `..` traversal, absolute paths outside the workspace, and in-workspace symlinks pointing outside it are all refused before the `agent-browser` CLI runs. Set `MCP_WORKSPACE_PATH` explicitly to control exactly where page captures land and which files the model can attach to a page. A `MCP_WORKSPACE_PATH` that cannot be resolved fails closed: file tools refuse to run rather than fall back to weaker checks.
|
|
210
|
+
|
|
211
|
+
Both tools are marked `destructiveHint: true`, so hosts can require user confirmation: `browser_upload` can trigger an immediate remote upload on pages that submit when the file input changes, and `browser_pdf` writes a local file.
|
|
212
|
+
|
|
213
|
+
Additional hardening:
|
|
214
|
+
|
|
215
|
+
- **Staged file hand-off.** Validated files are never re-opened by pathname. `browser_upload` sources are opened once, verified to be regular files (directories, devices, and FIFOs are refused), and copied into a fresh private staging directory that the CLI consumes. `browser_pdf` has the CLI write into a fresh private staging directory, then installs the PDF at the requested path: the destination directory's canonical identity is pinned before the CLI runs and re-verified before installing, so an intermediate directory swapped to a symlink mid-call is refused, and exclusive-create semantics refuse a file or symlink planted at the destination leaf instead of writing through it.
|
|
216
|
+
- **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`.
|
|
217
|
+
- **Enveloped errors.** Error output from the `agent-browser` CLI can contain page-authored text; it is wrapped in `<untrusted-content>` envelopes (with close-tag breakout escaping) before reaching the model, and timeout errors do not echo the command's argument values.
|
|
209
218
|
|
|
210
219
|
## Licence
|
|
211
220
|
|
package/dist/browser-client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { ConnectorError, DEFAULT_TIMEOUT_MS, SESSION_NAME } from './types.js';
|
|
4
|
+
import { wrapUntrusted } from './untrusted-content.js';
|
|
4
5
|
const execFileAsync = promisify(execFile);
|
|
5
6
|
let resolvedBinary = null;
|
|
6
7
|
function resolveAgentBrowser() {
|
|
@@ -49,8 +50,30 @@ function resolveHeaded(optionHeaded, env) {
|
|
|
49
50
|
* Why pinned: keeps fallback behavior reproducible. Bump when verified against
|
|
50
51
|
* a newer release. Do not use `latest` — npx caches by spec, and an unpinned
|
|
51
52
|
* spec produces flaky behavior across machines.
|
|
53
|
+
*
|
|
54
|
+
* 0.33.2 verified 2026-08-05 against the connector's full command surface
|
|
55
|
+
* (open, snapshot -i, screenshot, pdf, get url/title/text, tab, click/fill/
|
|
56
|
+
* type/press/scroll/select/hover, eval, back/forward, wait, close).
|
|
57
|
+
*/
|
|
58
|
+
const NPX_FALLBACK_VERSION = '0.33.2';
|
|
59
|
+
/**
|
|
60
|
+
* Build the ConnectorError message for a failed CLI invocation.
|
|
61
|
+
*
|
|
62
|
+
* stdout/stderr from the CLI can carry page-derived, attacker-controlled
|
|
63
|
+
* text (including prompt injection and forged envelope close tags), so the
|
|
64
|
+
* detail is wrapped in an untrusted-content envelope before it can reach the
|
|
65
|
+
* model. When neither stream has content, fall back to a static description
|
|
66
|
+
* naming only the subcommand: Node's own error message echoes the full
|
|
67
|
+
* argument vector, which may carry sensitive values (URLs with userinfo,
|
|
68
|
+
* typed text, evaluation scripts).
|
|
52
69
|
*/
|
|
53
|
-
|
|
70
|
+
function cliFailureMessage(command, stderr, stdout) {
|
|
71
|
+
const detail = stderr?.trim() || stdout?.trim() || '';
|
|
72
|
+
if (!detail) {
|
|
73
|
+
return `agent-browser ${command} exited with a non-zero status and produced no error output`;
|
|
74
|
+
}
|
|
75
|
+
return `agent-browser ${command} failed: ${wrapUntrusted(detail, 'browser-automation:cli-error')}`;
|
|
76
|
+
}
|
|
54
77
|
/**
|
|
55
78
|
* Execute an agent-browser CLI command.
|
|
56
79
|
*
|
|
@@ -110,22 +133,20 @@ export async function execAgentBrowser(args, options) {
|
|
|
110
133
|
'Or ensure npx is available on PATH.');
|
|
111
134
|
}
|
|
112
135
|
// npx ran but the underlying CLI exited non-zero — propagate as CLI_ERROR
|
|
113
|
-
// with the
|
|
114
|
-
|
|
115
|
-
const npxStdout = npxErr.stdout?.trim() ?? '';
|
|
116
|
-
throw new ConnectorError(npxStderr || npxStdout || npxErr.message || String(npxError), 'CLI_ERROR', 'The agent-browser CLI command failed (via npx fallback). ' +
|
|
136
|
+
// with the (enveloped) stderr for diagnosis.
|
|
137
|
+
throw new ConnectorError(cliFailureMessage(args[0], npxErr.stderr, npxErr.stdout), 'CLI_ERROR', 'The agent-browser CLI command failed (via npx fallback). ' +
|
|
117
138
|
'Check the error details above. ' +
|
|
118
139
|
'For best performance, install agent-browser globally: npm install -g agent-browser');
|
|
119
140
|
}
|
|
120
141
|
}
|
|
121
|
-
// Timeout
|
|
142
|
+
// Timeout — name only the subcommand: the full argument vector may carry
|
|
143
|
+
// sensitive values (URLs with userinfo, selectors, filenames, typed
|
|
144
|
+
// values, evaluation scripts) and must not be echoed to the model.
|
|
122
145
|
if (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || err.killed) {
|
|
123
|
-
throw new ConnectorError(`Command timed out after ${timeoutMs}ms: agent-browser ${args
|
|
146
|
+
throw new ConnectorError(`Command timed out after ${timeoutMs}ms: agent-browser ${args[0]}`, 'TIMEOUT', 'The browser operation took too long. Try a simpler action or increase the timeout.');
|
|
124
147
|
}
|
|
125
|
-
// Other errors — include stderr for diagnostics
|
|
126
|
-
|
|
127
|
-
const stdout = err.stdout?.trim() ?? '';
|
|
128
|
-
throw new ConnectorError(stderr || stdout || err.message || String(error), 'CLI_ERROR', 'The agent-browser CLI command failed. Check that agent-browser is installed and the browser session is active.');
|
|
148
|
+
// Other errors — include the (enveloped) stderr for diagnostics
|
|
149
|
+
throw new ConnectorError(cliFailureMessage(args[0], err.stderr, err.stdout), 'CLI_ERROR', 'The agent-browser CLI command failed. Check that agent-browser is installed and the browser session is active.');
|
|
129
150
|
}
|
|
130
151
|
}
|
|
131
152
|
/**
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type ResolveResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
path: string;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Derive the canonical workspace root for files the connector reads from or
|
|
10
|
+
* writes to on behalf of the model (`browser_pdf` output, `browser_upload`
|
|
11
|
+
* sources).
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the pandadoc connector's sandbox helper:
|
|
14
|
+
* - Use `MCP_WORKSPACE_PATH` if set and non-empty, else `os.tmpdir()`.
|
|
15
|
+
* - The root is canonicalised through `fs.realpathSync` so the prefix
|
|
16
|
+
* checks are stable on platforms where the tmpdir itself is reached
|
|
17
|
+
* through a symlink (macOS: `/tmp` → `/private/tmp`).
|
|
18
|
+
* - If `realpathSync` fails (e.g. the env var points at a non-existent
|
|
19
|
+
* directory), FAIL CLOSED: a root we cannot canonicalise makes every
|
|
20
|
+
* downstream containment check unreliable, so file operations are
|
|
21
|
+
* refused. The failed pre-check is made observable via a stderr warning.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getWorkspaceRoot(): string;
|
|
24
|
+
/**
|
|
25
|
+
* Validate that `inputPath` resolves to a real file inside the workspace
|
|
26
|
+
* root, even after symlink resolution (for `browser_upload` sources).
|
|
27
|
+
* Returns the canonical path on success.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveWorkspaceReadPath(inputPath: string): ResolveResult;
|
|
30
|
+
/**
|
|
31
|
+
* Validate that `inputPath` is a safe WRITE target inside the workspace
|
|
32
|
+
* root (for `browser_pdf` output). The leaf need not exist yet; when it
|
|
33
|
+
* does exist it must not be a symlink escaping the workspace.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveWorkspaceWritePath(inputPath: string): ResolveResult;
|
|
36
|
+
/**
|
|
37
|
+
* Create a fresh, unpredictable staging directory directly under the
|
|
38
|
+
* canonical workspace root (mode 0700). Because it is created atomically
|
|
39
|
+
* with a random suffix, another local principal cannot pre-create, rename,
|
|
40
|
+
* or symlink-swap any component of paths inside it — which is what makes a
|
|
41
|
+
* staged file swap-proof between our write and the agent-browser CLI's
|
|
42
|
+
* later open. The validated user-supplied pathname is never re-trusted.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createStagingDir(prefix: string): string;
|
|
45
|
+
/** Best-effort removal of a staging directory and everything in it. */
|
|
46
|
+
export declare function discardStagingDir(stagingDir: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Stage one `browser_upload` source into `stagingDir` and return the path
|
|
49
|
+
* the CLI should consume.
|
|
50
|
+
*
|
|
51
|
+
* The source is validated inside the workspace root, then opened EXACTLY
|
|
52
|
+
* ONCE and fstat-verified through that fd to be a regular file —
|
|
53
|
+
* directories, FIFOs, sockets, and devices pass realpathSync but must not
|
|
54
|
+
* become upload payloads. The content is streamed through the same fd into
|
|
55
|
+
* a private staging slot carrying over only the requested basename, so the
|
|
56
|
+
* CLI never re-opens the validated pathname and a post-validation swap of
|
|
57
|
+
* the original cannot redirect the upload.
|
|
58
|
+
*/
|
|
59
|
+
export declare function stageUploadSource(inputPath: string, stagingDir: string, index: number): string;
|
|
60
|
+
export interface PdfStagingTarget {
|
|
61
|
+
/** Validated final destination for the PDF. */
|
|
62
|
+
destPath: string;
|
|
63
|
+
/**
|
|
64
|
+
* Canonical realpath of destPath's parent directory, pinned at validation
|
|
65
|
+
* time (the parent is created before the CLI runs). installStagedFile
|
|
66
|
+
* re-verifies it before writing, so an intermediate directory swapped to
|
|
67
|
+
* a symlink during the CLI call is refused instead of followed outside
|
|
68
|
+
* the workspace.
|
|
69
|
+
*/
|
|
70
|
+
canonicalParentDir: string;
|
|
71
|
+
/** Private staging path the CLI should write to. */
|
|
72
|
+
stagingPath: string;
|
|
73
|
+
/** Remove with discardStagingDir once installed (or on failure). */
|
|
74
|
+
stagingDir: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the `browser_pdf` destination and prepare a private staging
|
|
78
|
+
* target. The CLI writes into the staging path — never the validated
|
|
79
|
+
* pathname — so an attacker cannot redirect the write by swapping an
|
|
80
|
+
* intermediate directory or leaf after validation.
|
|
81
|
+
*
|
|
82
|
+
* An existing destination is refused up front unless `overwrite` was
|
|
83
|
+
* explicitly requested; installStagedFile enforces the same invariant
|
|
84
|
+
* race-safely with exclusive-create at install time.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createPdfStagingTarget(filePath: string, overwrite: boolean): PdfStagingTarget;
|
|
87
|
+
/**
|
|
88
|
+
* Install a staged PDF at its validated destination.
|
|
89
|
+
*
|
|
90
|
+
* Two defences, one per path component class:
|
|
91
|
+
* - Leaf: the install never writes through a pre-existing filesystem
|
|
92
|
+
* entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
|
|
93
|
+
* already at the destination — including a planted symlink. With
|
|
94
|
+
* `overwrite`, the old entry is removed first and the same
|
|
95
|
+
* exclusive-create follows, so a leaf swap planted in between surfaces
|
|
96
|
+
* as an EEXIST refusal instead of a write through an attacker-chosen
|
|
97
|
+
* path.
|
|
98
|
+
* - Intermediate directories: exclusive-create guards only the final
|
|
99
|
+
* component, so the parent's canonical identity (pinned at validation,
|
|
100
|
+
* before the CLI ran) is re-verified immediately before installing. A
|
|
101
|
+
* directory swapped to a symlink during the CLI call is refused rather
|
|
102
|
+
* than followed outside the workspace — for the write and, with
|
|
103
|
+
* `overwrite`, for the preceding delete. (A swap landing after this
|
|
104
|
+
* re-check but before the copy is a sub-millisecond residual window, on
|
|
105
|
+
* par with any local check-then-use race.)
|
|
106
|
+
*/
|
|
107
|
+
export declare function installStagedFile(target: PdfStagingTarget, overwrite: boolean): void;
|
|
108
|
+
//# sourceMappingURL=path-safety.d.ts.map
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { ConnectorError } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Derive the canonical workspace root for files the connector reads from or
|
|
7
|
+
* writes to on behalf of the model (`browser_pdf` output, `browser_upload`
|
|
8
|
+
* sources).
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the pandadoc connector's sandbox helper:
|
|
11
|
+
* - Use `MCP_WORKSPACE_PATH` if set and non-empty, else `os.tmpdir()`.
|
|
12
|
+
* - The root is canonicalised through `fs.realpathSync` so the prefix
|
|
13
|
+
* checks are stable on platforms where the tmpdir itself is reached
|
|
14
|
+
* through a symlink (macOS: `/tmp` → `/private/tmp`).
|
|
15
|
+
* - If `realpathSync` fails (e.g. the env var points at a non-existent
|
|
16
|
+
* directory), FAIL CLOSED: a root we cannot canonicalise makes every
|
|
17
|
+
* downstream containment check unreliable, so file operations are
|
|
18
|
+
* refused. The failed pre-check is made observable via a stderr warning.
|
|
19
|
+
*/
|
|
20
|
+
export function getWorkspaceRoot() {
|
|
21
|
+
const envRoot = process.env.MCP_WORKSPACE_PATH;
|
|
22
|
+
const explicit = Boolean(envRoot && envRoot.trim());
|
|
23
|
+
const raw = explicit ? envRoot.trim() : os.tmpdir();
|
|
24
|
+
const lexical = path.resolve(raw);
|
|
25
|
+
try {
|
|
26
|
+
return fs.realpathSync(lexical);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
30
|
+
console.error(`[browser-automation] Workspace root ${JSON.stringify(lexical)} cannot be canonicalised (${reason}); refusing file operations.`);
|
|
31
|
+
throw new ConnectorError(explicit
|
|
32
|
+
? `MCP_WORKSPACE_PATH (${lexical}) cannot be resolved: ${reason}`
|
|
33
|
+
: `System temp directory (${lexical}) cannot be resolved: ${reason}`, 'WORKSPACE_ROOT_UNAVAILABLE', 'Set MCP_WORKSPACE_PATH to an existing directory the process can read, or unset it to use the system temp directory.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Canonicalise the deepest existing ancestor of an absolute path and
|
|
38
|
+
* re-append the missing tail. This lets the lexical-prefix containment check
|
|
39
|
+
* accept in-workspace paths supplied via a symlinked alias of the workspace
|
|
40
|
+
* root (e.g. `/tmp` → `/private/tmp` on macOS) while still rejecting `..`
|
|
41
|
+
* traversal and out-of-root absolutes deterministically WITHOUT requiring
|
|
42
|
+
* the leaf to exist.
|
|
43
|
+
*/
|
|
44
|
+
function canonicalisePrefix(absoluteLexical) {
|
|
45
|
+
const tail = [];
|
|
46
|
+
let cur = absoluteLexical;
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
const real = fs.realpathSync(cur);
|
|
50
|
+
return tail.length === 0 ? real : path.join(real, ...tail.reverse());
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const code = err.code;
|
|
54
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parent = path.dirname(cur);
|
|
59
|
+
if (parent === cur) {
|
|
60
|
+
return absoluteLexical;
|
|
61
|
+
}
|
|
62
|
+
tail.push(path.basename(cur));
|
|
63
|
+
cur = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function isInsideRoot(p, root) {
|
|
67
|
+
return p === root || p.startsWith(root + path.sep);
|
|
68
|
+
}
|
|
69
|
+
function absolutise(inputPath, root) {
|
|
70
|
+
const expanded = inputPath.startsWith('~')
|
|
71
|
+
? path.join(os.homedir(), inputPath.slice(1))
|
|
72
|
+
: inputPath;
|
|
73
|
+
// Relative inputs resolve against the workspace root — an MCP server's
|
|
74
|
+
// process cwd is unpredictable, so anchoring anywhere else would be
|
|
75
|
+
// arbitrary. Absolute inputs must still land inside the root.
|
|
76
|
+
return path.isAbsolute(expanded)
|
|
77
|
+
? path.resolve(expanded)
|
|
78
|
+
: path.resolve(root, expanded);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Validate that `inputPath` resolves to a real file inside the workspace
|
|
82
|
+
* root, even after symlink resolution (for `browser_upload` sources).
|
|
83
|
+
* Returns the canonical path on success.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveWorkspaceReadPath(inputPath) {
|
|
86
|
+
const root = getWorkspaceRoot();
|
|
87
|
+
const lexical = absolutise(inputPath, root);
|
|
88
|
+
const canonicalCandidate = canonicalisePrefix(lexical);
|
|
89
|
+
if (!isInsideRoot(canonicalCandidate, root)) {
|
|
90
|
+
return { ok: false, error: `file_path is outside the workspace sandbox root (${root}). Got: ${inputPath}` };
|
|
91
|
+
}
|
|
92
|
+
let canonical;
|
|
93
|
+
try {
|
|
94
|
+
canonical = fs.realpathSync(lexical);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err.code === 'ENOENT') {
|
|
98
|
+
return { ok: false, error: `File not found: ${inputPath}` };
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
if (!isInsideRoot(canonical, root)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `file_path resolves outside the workspace sandbox root (${root}); ` +
|
|
106
|
+
`symlinks may not escape the workspace. Got: ${inputPath}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, path: canonical };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Validate that `inputPath` is a safe WRITE target inside the workspace
|
|
113
|
+
* root (for `browser_pdf` output). The leaf need not exist yet; when it
|
|
114
|
+
* does exist it must not be a symlink escaping the workspace.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveWorkspaceWritePath(inputPath) {
|
|
117
|
+
const root = getWorkspaceRoot();
|
|
118
|
+
const lexical = absolutise(inputPath, root);
|
|
119
|
+
// Deepest-existing-ancestor canonicalisation catches `..` traversal,
|
|
120
|
+
// out-of-root absolutes, and symlinked intermediate directories pointing
|
|
121
|
+
// outside the root — all without requiring the leaf file to exist.
|
|
122
|
+
const canonicalCandidate = canonicalisePrefix(lexical);
|
|
123
|
+
if (!isInsideRoot(canonicalCandidate, root)) {
|
|
124
|
+
return { ok: false, error: `file_path is outside the workspace sandbox root (${root}). Got: ${inputPath}` };
|
|
125
|
+
}
|
|
126
|
+
// If the leaf itself exists as a symlink, resolve it too so a planted
|
|
127
|
+
// symlink cannot redirect the write outside the workspace.
|
|
128
|
+
try {
|
|
129
|
+
const canonicalLeaf = fs.realpathSync(lexical);
|
|
130
|
+
if (!isInsideRoot(canonicalLeaf, root)) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `file_path resolves outside the workspace sandbox root (${root}); ` +
|
|
134
|
+
`symlinks may not escape the workspace. Got: ${inputPath}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
if (err.code !== 'ENOENT') {
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, path: canonicalCandidate };
|
|
144
|
+
}
|
|
145
|
+
const STAGING_DIR_MODE = 0o700;
|
|
146
|
+
const STAGING_FILE_MODE = 0o600;
|
|
147
|
+
const COPY_CHUNK_BYTES = 1024 * 1024;
|
|
148
|
+
/**
|
|
149
|
+
* Create a fresh, unpredictable staging directory directly under the
|
|
150
|
+
* canonical workspace root (mode 0700). Because it is created atomically
|
|
151
|
+
* with a random suffix, another local principal cannot pre-create, rename,
|
|
152
|
+
* or symlink-swap any component of paths inside it — which is what makes a
|
|
153
|
+
* staged file swap-proof between our write and the agent-browser CLI's
|
|
154
|
+
* later open. The validated user-supplied pathname is never re-trusted.
|
|
155
|
+
*/
|
|
156
|
+
export function createStagingDir(prefix) {
|
|
157
|
+
const root = getWorkspaceRoot();
|
|
158
|
+
const dir = fs.mkdtempSync(path.join(root, prefix));
|
|
159
|
+
fs.chmodSync(dir, STAGING_DIR_MODE);
|
|
160
|
+
return dir;
|
|
161
|
+
}
|
|
162
|
+
/** Best-effort removal of a staging directory and everything in it. */
|
|
163
|
+
export function discardStagingDir(stagingDir) {
|
|
164
|
+
try {
|
|
165
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
catch { /* best effort */ }
|
|
168
|
+
}
|
|
169
|
+
function sanitiseBasename(p, fallback) {
|
|
170
|
+
const base = path.basename(p);
|
|
171
|
+
return base === '' || base === '.' || base === '..' ? fallback : base;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Stage one `browser_upload` source into `stagingDir` and return the path
|
|
175
|
+
* the CLI should consume.
|
|
176
|
+
*
|
|
177
|
+
* The source is validated inside the workspace root, then opened EXACTLY
|
|
178
|
+
* ONCE and fstat-verified through that fd to be a regular file —
|
|
179
|
+
* directories, FIFOs, sockets, and devices pass realpathSync but must not
|
|
180
|
+
* become upload payloads. The content is streamed through the same fd into
|
|
181
|
+
* a private staging slot carrying over only the requested basename, so the
|
|
182
|
+
* CLI never re-opens the validated pathname and a post-validation swap of
|
|
183
|
+
* the original cannot redirect the upload.
|
|
184
|
+
*/
|
|
185
|
+
export function stageUploadSource(inputPath, stagingDir, index) {
|
|
186
|
+
const resolved = resolveWorkspaceReadPath(inputPath);
|
|
187
|
+
if (!resolved.ok) {
|
|
188
|
+
throw new ConnectorError(resolved.error, 'PATH_OUTSIDE_WORKSPACE', 'Pass file paths inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset).');
|
|
189
|
+
}
|
|
190
|
+
const slot = path.join(stagingDir, String(index));
|
|
191
|
+
fs.mkdirSync(slot, { mode: STAGING_DIR_MODE });
|
|
192
|
+
const stagingPath = path.join(slot, sanitiseBasename(resolved.path, `upload-${index}`));
|
|
193
|
+
const inFd = fs.openSync(resolved.path, 'r');
|
|
194
|
+
try {
|
|
195
|
+
const stat = fs.fstatSync(inFd);
|
|
196
|
+
if (!stat.isFile()) {
|
|
197
|
+
throw new ConnectorError(`file_path is not a regular file: ${inputPath}`, 'NOT_A_REGULAR_FILE', 'Pass a path to a regular file inside the workspace directory.');
|
|
198
|
+
}
|
|
199
|
+
const outFd = fs.openSync(stagingPath, 'wx', STAGING_FILE_MODE);
|
|
200
|
+
try {
|
|
201
|
+
const chunk = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
|
|
202
|
+
let bytesRead;
|
|
203
|
+
do {
|
|
204
|
+
bytesRead = fs.readSync(inFd, chunk, 0, chunk.length, null);
|
|
205
|
+
if (bytesRead > 0)
|
|
206
|
+
fs.writeSync(outFd, chunk, 0, bytesRead);
|
|
207
|
+
} while (bytesRead > 0);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
fs.closeSync(outFd);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
fs.closeSync(inFd);
|
|
215
|
+
}
|
|
216
|
+
return stagingPath;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the `browser_pdf` destination and prepare a private staging
|
|
220
|
+
* target. The CLI writes into the staging path — never the validated
|
|
221
|
+
* pathname — so an attacker cannot redirect the write by swapping an
|
|
222
|
+
* intermediate directory or leaf after validation.
|
|
223
|
+
*
|
|
224
|
+
* An existing destination is refused up front unless `overwrite` was
|
|
225
|
+
* explicitly requested; installStagedFile enforces the same invariant
|
|
226
|
+
* race-safely with exclusive-create at install time.
|
|
227
|
+
*/
|
|
228
|
+
export function createPdfStagingTarget(filePath, overwrite) {
|
|
229
|
+
const resolved = resolveWorkspaceWritePath(filePath);
|
|
230
|
+
if (!resolved.ok) {
|
|
231
|
+
throw new ConnectorError(resolved.error, 'PATH_OUTSIDE_WORKSPACE', 'Pass a file path inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset).');
|
|
232
|
+
}
|
|
233
|
+
if (!overwrite && fs.existsSync(resolved.path)) {
|
|
234
|
+
throw new ConnectorError(`file_path already exists: ${filePath}`, 'FILE_EXISTS', 'Pass overwrite: true to replace the existing file, or choose a different file_path.');
|
|
235
|
+
}
|
|
236
|
+
// Create the parent directory NOW, before the multi-second CLI call, and
|
|
237
|
+
// pin its canonical identity. resolved.path is canonical, so the realpath
|
|
238
|
+
// of the freshly prepared parent must equal it exactly; a mismatch means
|
|
239
|
+
// a component changed under us and the destination cannot be trusted.
|
|
240
|
+
const parentDir = path.dirname(resolved.path);
|
|
241
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
242
|
+
const canonicalParentDir = fs.realpathSync(parentDir);
|
|
243
|
+
if (canonicalParentDir !== parentDir) {
|
|
244
|
+
throw new ConnectorError(`file_path parent directory changed while it was being prepared: ${filePath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
245
|
+
}
|
|
246
|
+
const stagingDir = createStagingDir('browser-pdf-');
|
|
247
|
+
return {
|
|
248
|
+
destPath: resolved.path,
|
|
249
|
+
canonicalParentDir,
|
|
250
|
+
stagingPath: path.join(stagingDir, sanitiseBasename(resolved.path, 'page.pdf')),
|
|
251
|
+
stagingDir,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Install a staged PDF at its validated destination.
|
|
256
|
+
*
|
|
257
|
+
* Two defences, one per path component class:
|
|
258
|
+
* - Leaf: the install never writes through a pre-existing filesystem
|
|
259
|
+
* entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
|
|
260
|
+
* already at the destination — including a planted symlink. With
|
|
261
|
+
* `overwrite`, the old entry is removed first and the same
|
|
262
|
+
* exclusive-create follows, so a leaf swap planted in between surfaces
|
|
263
|
+
* as an EEXIST refusal instead of a write through an attacker-chosen
|
|
264
|
+
* path.
|
|
265
|
+
* - Intermediate directories: exclusive-create guards only the final
|
|
266
|
+
* component, so the parent's canonical identity (pinned at validation,
|
|
267
|
+
* before the CLI ran) is re-verified immediately before installing. A
|
|
268
|
+
* directory swapped to a symlink during the CLI call is refused rather
|
|
269
|
+
* than followed outside the workspace — for the write and, with
|
|
270
|
+
* `overwrite`, for the preceding delete. (A swap landing after this
|
|
271
|
+
* re-check but before the copy is a sub-millisecond residual window, on
|
|
272
|
+
* par with any local check-then-use race.)
|
|
273
|
+
*/
|
|
274
|
+
export function installStagedFile(target, overwrite) {
|
|
275
|
+
const { stagingPath, destPath, canonicalParentDir } = target;
|
|
276
|
+
let staged;
|
|
277
|
+
try {
|
|
278
|
+
staged = fs.lstatSync(stagingPath);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
throw new ConnectorError('The agent-browser CLI did not produce an output file.', 'CLI_ERROR', 'Retry the call, or check that a page is open in the browser session.');
|
|
282
|
+
}
|
|
283
|
+
if (!staged.isFile()) {
|
|
284
|
+
throw new ConnectorError('The agent-browser CLI did not produce a regular output file.', 'CLI_ERROR', 'Retry the call, or check that a page is open in the browser session.');
|
|
285
|
+
}
|
|
286
|
+
const parentDir = path.dirname(destPath);
|
|
287
|
+
let currentParentDir;
|
|
288
|
+
try {
|
|
289
|
+
currentParentDir = fs.realpathSync(parentDir);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
293
|
+
throw new ConnectorError(`Destination directory cannot be resolved (${reason}): ${destPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
294
|
+
}
|
|
295
|
+
if (currentParentDir !== canonicalParentDir) {
|
|
296
|
+
throw new ConnectorError(`Destination directory changed while the PDF was being generated: ${destPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
297
|
+
}
|
|
298
|
+
if (overwrite) {
|
|
299
|
+
fs.rmSync(destPath, { force: true });
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (err.code === 'EEXIST') {
|
|
306
|
+
throw new ConnectorError(`Destination already exists: ${destPath}`, 'FILE_EXISTS', 'Pass overwrite: true to replace the existing file, or choose a different file_path.');
|
|
307
|
+
}
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
//# sourceMappingURL=path-safety.js.map
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { SERVER_NAME, SERVER_VERSION } from './types.js';
|
|
3
|
-
import { registerNavigationTools, registerInteractionTools, registerObservationTools, registerSessionTools, } from './tools/index.js';
|
|
3
|
+
import { registerNavigationTools, registerInteractionTools, registerObservationTools, registerSessionTools, registerFileTools, } from './tools/index.js';
|
|
4
4
|
export function createServer() {
|
|
5
5
|
const server = new McpServer({
|
|
6
6
|
name: SERVER_NAME,
|
|
@@ -10,6 +10,7 @@ export function createServer() {
|
|
|
10
10
|
registerInteractionTools(server);
|
|
11
11
|
registerObservationTools(server);
|
|
12
12
|
registerSessionTools(server);
|
|
13
|
+
registerFileTools(server);
|
|
13
14
|
return server;
|
|
14
15
|
}
|
|
15
16
|
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { execAgentBrowser } from '../browser-client.js';
|
|
3
|
+
import { withErrorHandling } from '../utils.js';
|
|
4
|
+
import { createStagingDir, discardStagingDir, stageUploadSource } from '../path-safety.js';
|
|
5
|
+
export function registerFileTools(server) {
|
|
6
|
+
server.registerTool('browser_upload', {
|
|
7
|
+
description: `Upload one or more files to a file input on the page. Use @ref from browser_snapshot or a CSS selector for the <input type="file">.
|
|
8
|
+
|
|
9
|
+
WORKFLOW: browser_snapshot → find file input @ref → browser_upload.
|
|
10
|
+
|
|
11
|
+
Files must live inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset) — paths outside it are refused, and only regular files (not directories or devices) are accepted. Validated files are copied into a private staging directory before upload, so the upload cannot be redirected after validation.`,
|
|
12
|
+
inputSchema: {
|
|
13
|
+
ref: z.string().min(1).describe('Element ref (e.g., "@e3") or CSS selector for the file input'),
|
|
14
|
+
file_paths: z.array(z.string().min(1)).min(1).describe('Files to upload. Relative paths resolve inside the workspace directory; absolute paths must be inside it.'),
|
|
15
|
+
},
|
|
16
|
+
annotations: {
|
|
17
|
+
readOnlyHint: false,
|
|
18
|
+
// Uploading a file to a page can trigger an immediate remote upload
|
|
19
|
+
// (pages that submit on the input's change event) — an external,
|
|
20
|
+
// production-impacting write.
|
|
21
|
+
destructiveHint: true,
|
|
22
|
+
idempotentHint: false,
|
|
23
|
+
openWorldHint: true,
|
|
24
|
+
},
|
|
25
|
+
}, withErrorHandling(async (args) => {
|
|
26
|
+
// Stage every source into one private staging dir and hand the CLI the
|
|
27
|
+
// staged paths — never the validated pathnames — so a swap of the
|
|
28
|
+
// original file or an ancestor after validation cannot redirect the
|
|
29
|
+
// upload. The staging dir is always discarded afterwards.
|
|
30
|
+
const stagingDir = createStagingDir('browser-upload-');
|
|
31
|
+
try {
|
|
32
|
+
const staged = [];
|
|
33
|
+
for (const [index, filePath] of args.file_paths.entries()) {
|
|
34
|
+
staged.push(stageUploadSource(filePath, stagingDir, index));
|
|
35
|
+
}
|
|
36
|
+
await execAgentBrowser(['upload', args.ref, ...staged]);
|
|
37
|
+
return JSON.stringify({ ok: true, message: `Uploaded ${staged.length} file(s) to ${args.ref}` });
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
discardStagingDir(stagingDir);
|
|
41
|
+
}
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=files.js.map
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ export { registerNavigationTools } from './navigation.js';
|
|
|
2
2
|
export { registerInteractionTools } from './interaction.js';
|
|
3
3
|
export { registerObservationTools } from './observation.js';
|
|
4
4
|
export { registerSessionTools } from './session.js';
|
|
5
|
+
export { registerFileTools } from './files.js';
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/tools/index.js
CHANGED
|
@@ -2,4 +2,5 @@ export { registerNavigationTools } from './navigation.js';
|
|
|
2
2
|
export { registerInteractionTools } from './interaction.js';
|
|
3
3
|
export { registerObservationTools } from './observation.js';
|
|
4
4
|
export { registerSessionTools } from './session.js';
|
|
5
|
+
export { registerFileTools } from './files.js';
|
|
5
6
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { execAgentBrowser } from '../browser-client.js';
|
|
3
3
|
import { withErrorHandling } from '../utils.js';
|
|
4
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
4
5
|
export function registerInteractionTools(server) {
|
|
5
6
|
server.registerTool('browser_click', {
|
|
6
7
|
description: `Click an element. Use @ref from browser_snapshot (preferred) or a CSS selector.
|
|
@@ -118,32 +119,27 @@ WORKFLOW: browser_snapshot → find input @ref → browser_fill`,
|
|
|
118
119
|
}));
|
|
119
120
|
// M3.12 — `browser_evaluate` lets the model run arbitrary JavaScript inside
|
|
120
121
|
// the page context, which is the security equivalent of giving it a shell
|
|
121
|
-
// on whatever site it has just navigated to.
|
|
122
|
-
//
|
|
123
|
-
// host
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}, withErrorHandling(async (args) => {
|
|
144
|
-
const result = await execAgentBrowser(['eval', args.script]);
|
|
145
|
-
return JSON.stringify({ ok: true, result: result.stdout.trim() });
|
|
146
|
-
}));
|
|
147
|
-
}
|
|
122
|
+
// on whatever site it has just navigated to. The tool is registered
|
|
123
|
+
// unconditionally (capability-first): it carries `destructiveHint: true`
|
|
124
|
+
// so the host's tool-approval layer can require explicit user confirmation
|
|
125
|
+
// before each invocation.
|
|
126
|
+
server.registerTool('browser_evaluate', // eslint-disable-line @typescript-eslint/quotes
|
|
127
|
+
{
|
|
128
|
+
description: 'Execute JavaScript in the page context and return the result. ' +
|
|
129
|
+
'DESTRUCTIVE: this is equivalent to running arbitrary code with the privileges of the current page; ' +
|
|
130
|
+
'hosts SHOULD require user confirmation before each call.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
script: z.string().describe('JavaScript code to execute'),
|
|
133
|
+
},
|
|
134
|
+
annotations: {
|
|
135
|
+
readOnlyHint: false,
|
|
136
|
+
destructiveHint: true,
|
|
137
|
+
idempotentHint: false,
|
|
138
|
+
openWorldHint: true,
|
|
139
|
+
},
|
|
140
|
+
}, withErrorHandling(async (args) => {
|
|
141
|
+
const result = await execAgentBrowser(['eval', args.script]);
|
|
142
|
+
return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
|
|
143
|
+
}));
|
|
148
144
|
}
|
|
149
145
|
//# sourceMappingURL=interaction.js.map
|
package/dist/tools/navigation.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { execAgentBrowser } from '../browser-client.js';
|
|
3
3
|
import { validateUrlScheme, withErrorHandling } from '../utils.js';
|
|
4
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
4
5
|
// URL scheme deny-list (validated by `validateUrlScheme` in utils.ts):
|
|
5
6
|
// only http: and https: are permitted; about:blank is special-cased.
|
|
6
7
|
// Refused: file:, chrome:, chrome-extension:, javascript:, data:,
|
|
@@ -28,7 +29,7 @@ IMPORTANT: After navigating, call browser_snapshot to see the page content befor
|
|
|
28
29
|
return JSON.stringify({
|
|
29
30
|
ok: true,
|
|
30
31
|
message: `Navigated to ${args.url}`,
|
|
31
|
-
title: titleResult.stdout.trim(),
|
|
32
|
+
title: wrapUntrusted(titleResult.stdout.trim(), 'browser-automation:page-title'),
|
|
32
33
|
hint: 'Call browser_snapshot to see page elements before interacting.',
|
|
33
34
|
});
|
|
34
35
|
}));
|
|
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
|
|
2
2
|
import { execAgentBrowser } from '../browser-client.js';
|
|
3
3
|
import { withErrorHandling, withErrorHandlingRaw } from '../utils.js';
|
|
4
4
|
import { SNAPSHOT_TIMEOUT_MS, SCREENSHOT_TIMEOUT_MS } from '../types.js';
|
|
5
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
6
|
+
import { createPdfStagingTarget, discardStagingDir, installStagedFile } from '../path-safety.js';
|
|
5
7
|
export function registerObservationTools(server) {
|
|
6
8
|
server.registerTool('browser_snapshot', {
|
|
7
9
|
description: `Get the page accessibility tree with interactive element references.
|
|
@@ -22,7 +24,7 @@ Use the -i flag (default) to see only interactive elements, keeping output focus
|
|
|
22
24
|
}, withErrorHandling(async (args) => {
|
|
23
25
|
const cliArgs = args.full ? ['snapshot'] : ['snapshot', '-i'];
|
|
24
26
|
const result = await execAgentBrowser(cliArgs, { timeoutMs: SNAPSHOT_TIMEOUT_MS });
|
|
25
|
-
return JSON.stringify({ ok: true, snapshot: result.stdout });
|
|
27
|
+
return JSON.stringify({ ok: true, snapshot: wrapUntrusted(result.stdout, 'browser-automation:snapshot') });
|
|
26
28
|
}));
|
|
27
29
|
server.registerTool('browser_screenshot', {
|
|
28
30
|
description: 'Take a screenshot of the current page. Returns an image.',
|
|
@@ -56,7 +58,9 @@ Use the -i flag (default) to see only interactive elements, keeping output focus
|
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
return {
|
|
59
|
-
|
|
61
|
+
// Short stdout is not an image — it is CLI/page-derived text and must
|
|
62
|
+
// be enveloped like every other external string.
|
|
63
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Screenshot taken', note: wrapUntrusted(data, 'browser-automation:screenshot') }) }],
|
|
60
64
|
};
|
|
61
65
|
}));
|
|
62
66
|
server.registerTool('browser_get_page_info', {
|
|
@@ -73,9 +77,58 @@ Use the -i flag (default) to see only interactive elements, keeping output focus
|
|
|
73
77
|
const titleResult = await execAgentBrowser(['get', 'title']);
|
|
74
78
|
return JSON.stringify({
|
|
75
79
|
ok: true,
|
|
76
|
-
url: urlResult.stdout.trim(),
|
|
77
|
-
title: titleResult.stdout.trim(),
|
|
80
|
+
url: wrapUntrusted(urlResult.stdout.trim(), 'browser-automation:page-url'),
|
|
81
|
+
title: wrapUntrusted(titleResult.stdout.trim(), 'browser-automation:page-title'),
|
|
78
82
|
});
|
|
79
83
|
}));
|
|
84
|
+
server.registerTool('browser_get_text', {
|
|
85
|
+
description: `Get the text content of the page (or of a single element).
|
|
86
|
+
|
|
87
|
+
Use this for reading and summarising page content — it returns clean text, unlike browser_snapshot which returns the interactive-element accessibility tree.`,
|
|
88
|
+
inputSchema: {
|
|
89
|
+
ref: z.string().optional().describe('Element ref from snapshot (e.g., "@e2") or CSS selector. Omit to get the whole page body text.'),
|
|
90
|
+
},
|
|
91
|
+
annotations: {
|
|
92
|
+
readOnlyHint: true,
|
|
93
|
+
destructiveHint: false,
|
|
94
|
+
idempotentHint: true,
|
|
95
|
+
openWorldHint: true,
|
|
96
|
+
},
|
|
97
|
+
}, withErrorHandling(async (args) => {
|
|
98
|
+
const selector = args.ref ?? 'body';
|
|
99
|
+
const result = await execAgentBrowser(['get', 'text', selector]);
|
|
100
|
+
return JSON.stringify({ ok: true, text: wrapUntrusted(result.stdout.trim(), 'browser-automation:page-text') });
|
|
101
|
+
}));
|
|
102
|
+
server.registerTool('browser_pdf', {
|
|
103
|
+
description: `Save the current page as a PDF file.
|
|
104
|
+
|
|
105
|
+
The file is written inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset) — file_path must resolve inside that workspace. Existing files are refused unless overwrite is set to true.`,
|
|
106
|
+
inputSchema: {
|
|
107
|
+
file_path: z.string().min(1).describe('Target file path for the PDF. Relative paths resolve inside the workspace directory; absolute paths must be inside it (e.g., "reports/page.pdf").'),
|
|
108
|
+
overwrite: z.boolean().optional().default(false).describe('Replace an existing file at file_path. Default: false — existing files are refused.'),
|
|
109
|
+
},
|
|
110
|
+
annotations: {
|
|
111
|
+
readOnlyHint: false,
|
|
112
|
+
// Writes (and, with overwrite: true, replaces) a local file.
|
|
113
|
+
destructiveHint: true,
|
|
114
|
+
idempotentHint: false,
|
|
115
|
+
openWorldHint: true,
|
|
116
|
+
},
|
|
117
|
+
}, withErrorHandling(async (args) => {
|
|
118
|
+
// The CLI writes into a private staging dir. Install then re-verifies
|
|
119
|
+
// the destination directory's canonical identity (pinned before the
|
|
120
|
+
// CLI ran) and writes with exclusive-create, so a post-validation swap
|
|
121
|
+
// of an intermediate directory or the leaf is refused rather than
|
|
122
|
+
// followed outside the workspace.
|
|
123
|
+
const target = createPdfStagingTarget(args.file_path, args.overwrite);
|
|
124
|
+
try {
|
|
125
|
+
await execAgentBrowser(['pdf', target.stagingPath]);
|
|
126
|
+
installStagedFile(target, args.overwrite);
|
|
127
|
+
return JSON.stringify({ ok: true, file_path: target.destPath });
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
discardStagingDir(target.stagingDir);
|
|
131
|
+
}
|
|
132
|
+
}));
|
|
80
133
|
}
|
|
81
134
|
//# sourceMappingURL=observation.js.map
|
package/dist/tools/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { execAgentBrowser } from '../browser-client.js';
|
|
3
3
|
import { validateUrlScheme, withErrorHandling } from '../utils.js';
|
|
4
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
4
5
|
// URL scheme deny-list applied to browser_authenticate (validated by
|
|
5
6
|
// `validateUrlScheme` in utils.ts): only http: and https: URLs are
|
|
6
7
|
// permitted; about:blank is special-cased. Refused: file:, chrome:,
|
|
@@ -26,7 +27,7 @@ export function registerSessionTools(server) {
|
|
|
26
27
|
}
|
|
27
28
|
const cliAction = args.action ?? 'list';
|
|
28
29
|
const result = await execAgentBrowser(['tab', cliAction]);
|
|
29
|
-
return JSON.stringify({ ok: true, tabs: result.stdout.trim() });
|
|
30
|
+
return JSON.stringify({ ok: true, tabs: wrapUntrusted(result.stdout.trim(), 'browser-automation:tabs') });
|
|
30
31
|
}));
|
|
31
32
|
server.registerTool('browser_close', {
|
|
32
33
|
description: 'Close the browser session. Sessions are saved automatically.',
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTS.md security invariant #6 — content fetched from an external system
|
|
3
|
+
* MUST be wrapped in an `<untrusted-content source="…">…</untrusted-content>`
|
|
4
|
+
* envelope (with close-tag breakout escaping) before it is returned to the
|
|
5
|
+
* LLM, so the model treats third-party / attacker-controllable text as DATA,
|
|
6
|
+
* not as instructions.
|
|
7
|
+
*
|
|
8
|
+
* This is the canonical implementation a new connector ships with. It is a
|
|
9
|
+
* VENDORED copy of the shared reference in `test-harness/src/untrusted-content.ts`
|
|
10
|
+
* — connectors cannot `import` the test-harness at runtime (it is a
|
|
11
|
+
* test/dev-only `file:` dependency that is never published into a connector's
|
|
12
|
+
* `dist/`), so the helper lives in the connector's own runtime source. Keep
|
|
13
|
+
* this byte-for-byte in sync with the shared reference; do NOT weaken the
|
|
14
|
+
* escaping back to a simple `replaceAll` (that family misses whitespace / case
|
|
15
|
+
* close-tag variants like `</untrusted-content >` / `</UNTRUSTED-CONTENT>`).
|
|
16
|
+
*
|
|
17
|
+
* `scripts/check-untrusted-coverage.mjs` greps for a reference to
|
|
18
|
+
* `untrusted-content` in any connector that talks to an external system; this
|
|
19
|
+
* file (and the call sites that import from it) is what satisfies that gate.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Wrap a single untrusted string in an `<untrusted-content source="…">`
|
|
23
|
+
* envelope, escaping any embedded close-tag variant so the envelope cannot be
|
|
24
|
+
* broken out of. `undefined` passes through untouched. Idempotent for the same
|
|
25
|
+
* `source`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function wrapUntrusted(text: string | null | undefined, source: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Recursively wrap every string value reachable inside `value`. Object keys are
|
|
30
|
+
* structural and NOT wrapped; non-string leaves pass through unchanged.
|
|
31
|
+
*/
|
|
32
|
+
export declare function wrapUntrustedJsonStrings<T>(value: T, source: string): T;
|
|
33
|
+
//# sourceMappingURL=untrusted-content.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTS.md security invariant #6 — content fetched from an external system
|
|
3
|
+
* MUST be wrapped in an `<untrusted-content source="…">…</untrusted-content>`
|
|
4
|
+
* envelope (with close-tag breakout escaping) before it is returned to the
|
|
5
|
+
* LLM, so the model treats third-party / attacker-controllable text as DATA,
|
|
6
|
+
* not as instructions.
|
|
7
|
+
*
|
|
8
|
+
* This is the canonical implementation a new connector ships with. It is a
|
|
9
|
+
* VENDORED copy of the shared reference in `test-harness/src/untrusted-content.ts`
|
|
10
|
+
* — connectors cannot `import` the test-harness at runtime (it is a
|
|
11
|
+
* test/dev-only `file:` dependency that is never published into a connector's
|
|
12
|
+
* `dist/`), so the helper lives in the connector's own runtime source. Keep
|
|
13
|
+
* this byte-for-byte in sync with the shared reference; do NOT weaken the
|
|
14
|
+
* escaping back to a simple `replaceAll` (that family misses whitespace / case
|
|
15
|
+
* close-tag variants like `</untrusted-content >` / `</UNTRUSTED-CONTENT>`).
|
|
16
|
+
*
|
|
17
|
+
* `scripts/check-untrusted-coverage.mjs` greps for a reference to
|
|
18
|
+
* `untrusted-content` in any connector that talks to an external system; this
|
|
19
|
+
* file (and the call sites that import from it) is what satisfies that gate.
|
|
20
|
+
*/
|
|
21
|
+
const UNTRUSTED_CLOSE_TAG_VARIANT = /<\/untrusted-content\s*>/gi;
|
|
22
|
+
const ESCAPED_UNTRUSTED_CLOSE_TAG = '<\\/untrusted-content>';
|
|
23
|
+
function escapeAttr(s) {
|
|
24
|
+
return s.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
25
|
+
}
|
|
26
|
+
function escapeCloseTagSentinels(s) {
|
|
27
|
+
return s.replace(UNTRUSTED_CLOSE_TAG_VARIANT, ESCAPED_UNTRUSTED_CLOSE_TAG);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a single untrusted string in an `<untrusted-content source="…">`
|
|
31
|
+
* envelope, escaping any embedded close-tag variant so the envelope cannot be
|
|
32
|
+
* broken out of. `undefined` passes through untouched. Idempotent for the same
|
|
33
|
+
* `source`.
|
|
34
|
+
*/
|
|
35
|
+
export function wrapUntrusted(text, source) {
|
|
36
|
+
if (text === undefined || text === null)
|
|
37
|
+
return undefined;
|
|
38
|
+
const open = `<untrusted-content source="${escapeAttr(source)}">`;
|
|
39
|
+
const close = '</untrusted-content>';
|
|
40
|
+
if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) {
|
|
41
|
+
const inner = text.slice(open.length, text.length - close.length);
|
|
42
|
+
if (!UNTRUSTED_CLOSE_TAG_VARIANT.test(inner)) {
|
|
43
|
+
UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
|
|
47
|
+
}
|
|
48
|
+
return `${open}${escapeCloseTagSentinels(text)}${close}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Recursively wrap every string value reachable inside `value`. Object keys are
|
|
52
|
+
* structural and NOT wrapped; non-string leaves pass through unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export function wrapUntrustedJsonStrings(value, source) {
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
return wrapUntrusted(value, source);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value.map((item) => wrapUntrustedJsonStrings(item, source));
|
|
60
|
+
}
|
|
61
|
+
if (value && typeof value === 'object') {
|
|
62
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wrapUntrustedJsonStrings(item, source)]));
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=untrusted-content.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindstone/mcp-server-browser-automation",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"mcpName": "io.github.mindstone/mcp-server-browser-automation",
|
|
5
5
|
"description": "Browser automation MCP server — visible-by-default browser control via accessibility snapshots, navigation, form filling, screenshots, and tab management. Set AGENT_BROWSER_SHOW_WINDOW=false to run quietly.",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|