@mindstone/mcp-server-browser-automation 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.7](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-browser-automation)
12
+ - **Version:** [0.2.0](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-browser-automation)
13
13
  - **Auth:** None ([`server.json`](./server.json))
14
- - **Tools:** [18](./src/tools/) (navigation, observation, interaction, sessions)
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.26.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
 
@@ -118,6 +118,7 @@ No API keys or credentials are required. The server communicates with the browse
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
  | `BROWSER_AUTOMATION_ALLOW_EVAL` | No | Set to `1` to register the `browser_evaluate` tool. Off by default. See [Security notes](#security-notes). |
121
+ | `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
122
 
122
123
  ### MCP Host Configuration
123
124
 
@@ -132,7 +133,7 @@ No API keys or credentials are required. The server communicates with the browse
132
133
  }
133
134
  ```
134
135
 
135
- ## Available Tools (17 by default; +1 when `BROWSER_AUTOMATION_ALLOW_EVAL=1`)
136
+ ## Available Tools (20 by default; +1 when `BROWSER_AUTOMATION_ALLOW_EVAL=1`)
136
137
 
137
138
  ### Navigation
138
139
  - **browser_navigate** — Navigate to a URL
@@ -144,6 +145,8 @@ No API keys or credentials are required. The server communicates with the browse
144
145
  - **browser_snapshot** — Get the page accessibility tree with interactive element references
145
146
  - **browser_screenshot** — Take a screenshot of the current page
146
147
  - **browser_get_page_info** — Get the current page URL and title
148
+ - **browser_get_text** — Get the text content of the page or a single element
149
+ - **browser_pdf** — Save the current page as a PDF inside the workspace directory (refuses to overwrite existing files unless `overwrite: true`)
147
150
 
148
151
  ### Interaction
149
152
  - **browser_click** — Click an element using @ref or CSS selector
@@ -153,6 +156,7 @@ No API keys or credentials are required. The server communicates with the browse
153
156
  - **browser_scroll** — Scroll the page in a direction
154
157
  - **browser_select** — Select an option from a dropdown
155
158
  - **browser_hover** — Hover over an element
159
+ - **browser_upload** — Upload workspace files to a file input (regular files only, staged privately before upload)
156
160
  - **browser_evaluate** — Execute JavaScript in the page context (gated; see [Security notes](#security-notes))
157
161
 
158
162
  ### Session Management
@@ -205,7 +209,19 @@ To override the session name (for example, to keep separate profiles per project
205
209
  - **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
210
  - **Leave `browser_evaluate` disabled** unless the host implements user confirmation for every call. The default (off) is the safe choice.
207
211
  - **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
- - **Treat returned page content as untrusted** — accessibility snapshots, screenshots, and JavaScript-evaluation outputs come from arbitrary websites and may contain prompt-injection attempts.
212
+ - **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.
213
+
214
+ ### Workspace sandbox for file tools
215
+
216
+ `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.
217
+
218
+ 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.
219
+
220
+ Additional hardening:
221
+
222
+ - **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 and then installs the PDF at the requested path with exclusive-create semantics. A file or symlink planted after validation cannot redirect the read or the write.
223
+ - **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`.
224
+ - **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
225
 
210
226
  ## Licence
211
227
 
@@ -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
- const NPX_FALLBACK_VERSION = '0.26.0';
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 actual stderr for diagnosis.
114
- const npxStderr = npxErr.stderr?.trim() ?? '';
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.join(' ')}`, 'TIMEOUT', 'The browser operation took too long. Try a simpler action or increase the timeout.');
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
- const stderr = err.stderr?.trim() ?? '';
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,90 @@
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
+ /** Private staging path the CLI should write to. */
64
+ stagingPath: string;
65
+ /** Remove with discardStagingDir once installed (or on failure). */
66
+ stagingDir: string;
67
+ }
68
+ /**
69
+ * Resolve the `browser_pdf` destination and prepare a private staging
70
+ * target. The CLI writes into the staging path — never the validated
71
+ * pathname — so an attacker cannot redirect the write by swapping an
72
+ * intermediate directory or leaf after validation.
73
+ *
74
+ * An existing destination is refused up front unless `overwrite` was
75
+ * explicitly requested; installStagedFile enforces the same invariant
76
+ * race-safely with exclusive-create at install time.
77
+ */
78
+ export declare function createPdfStagingTarget(filePath: string, overwrite: boolean): PdfStagingTarget;
79
+ /**
80
+ * Install a staged PDF at its validated destination.
81
+ *
82
+ * The install never writes through a pre-existing filesystem entry:
83
+ * exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything already at
84
+ * the destination — including a planted symlink. With `overwrite`, the old
85
+ * entry is removed first and the same exclusive-create follows, so a swap
86
+ * planted in between still surfaces as an EEXIST refusal instead of a write
87
+ * through an attacker-chosen path.
88
+ */
89
+ export declare function installStagedFile(stagingPath: string, destPath: string, overwrite: boolean): void;
90
+ //# sourceMappingURL=path-safety.d.ts.map
@@ -0,0 +1,280 @@
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
+ const stagingDir = createStagingDir('browser-pdf-');
237
+ return {
238
+ destPath: resolved.path,
239
+ stagingPath: path.join(stagingDir, sanitiseBasename(resolved.path, 'page.pdf')),
240
+ stagingDir,
241
+ };
242
+ }
243
+ /**
244
+ * Install a staged PDF at its validated destination.
245
+ *
246
+ * The install never writes through a pre-existing filesystem entry:
247
+ * exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything already at
248
+ * the destination — including a planted symlink. With `overwrite`, the old
249
+ * entry is removed first and the same exclusive-create follows, so a swap
250
+ * planted in between still surfaces as an EEXIST refusal instead of a write
251
+ * through an attacker-chosen path.
252
+ */
253
+ export function installStagedFile(stagingPath, destPath, overwrite) {
254
+ let staged;
255
+ try {
256
+ staged = fs.lstatSync(stagingPath);
257
+ }
258
+ catch {
259
+ 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.');
260
+ }
261
+ if (!staged.isFile()) {
262
+ 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.');
263
+ }
264
+ if (overwrite) {
265
+ fs.rmSync(destPath, { force: true });
266
+ }
267
+ // Parent directories were validated as inside the workspace by
268
+ // resolveWorkspaceWritePath; create any missing tail before installing.
269
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
270
+ try {
271
+ fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
272
+ }
273
+ catch (err) {
274
+ if (err.code === 'EEXIST') {
275
+ throw new ConnectorError(`Destination already exists: ${destPath}`, 'FILE_EXISTS', 'Pass overwrite: true to replace the existing file, or choose a different file_path.');
276
+ }
277
+ throw err;
278
+ }
279
+ }
280
+ //# 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,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerFileTools(server: McpServer): void;
3
+ //# sourceMappingURL=files.d.ts.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
@@ -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
@@ -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.
@@ -142,7 +143,7 @@ WORKFLOW: browser_snapshot → find input @ref → browser_fill`,
142
143
  },
143
144
  }, withErrorHandling(async (args) => {
144
145
  const result = await execAgentBrowser(['eval', args.script]);
145
- return JSON.stringify({ ok: true, result: result.stdout.trim() });
146
+ return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
146
147
  }));
147
148
  }
148
149
  }
@@ -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
- content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Screenshot taken', note: data }) }],
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,57 @@ 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, and the PDF is installed
119
+ // at the validated destination with exclusive-create semantics, so a
120
+ // post-validation swap cannot redirect the write or clobber an
121
+ // existing file.
122
+ const target = createPdfStagingTarget(args.file_path, args.overwrite);
123
+ try {
124
+ await execAgentBrowser(['pdf', target.stagingPath]);
125
+ installStagedFile(target.stagingPath, target.destPath, args.overwrite);
126
+ return JSON.stringify({ ok: true, file_path: target.destPath });
127
+ }
128
+ finally {
129
+ discardStagingDir(target.stagingDir);
130
+ }
131
+ }));
80
132
  }
81
133
  //# sourceMappingURL=observation.js.map
@@ -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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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.8",
3
+ "version": "0.2.0",
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",