@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 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.1](./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
 
@@ -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
- | `BROWSER_AUTOMATION_ALLOW_EVAL` | No | Set to `1` to register the `browser_evaluate` tool. Off by default. See [Security notes](#security-notes). |
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 (17 by default; +1 when `BROWSER_AUTOMATION_ALLOW_EVAL=1`)
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
- - **browser_evaluate** — Execute JavaScript in the page context (gated; see [Security notes](#security-notes))
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 gated behind `BROWSER_AUTOMATION_ALLOW_EVAL`
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. To prevent prompt-injected content from doing this silently, the tool is **only registered when the host explicitly opts in**:
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
- - **Leave `browser_evaluate` disabled** unless the host implements user confirmation for every call. The default (off) is the safe choice.
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
- - **Treat returned page content as untrusted** — accessibility snapshots, screenshots, and JavaScript-evaluation outputs come from arbitrary websites and may contain prompt-injection attempts.
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
 
@@ -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,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,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.
@@ -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. To prevent prompt-injected
122
- // content from doing this silently, the tool is registered ONLY when the
123
- // host explicitly opts in via `BROWSER_AUTOMATION_ALLOW_EVAL=1`. Without
124
- // that env var the tool is not in the tools list at all (the LLM cannot
125
- // even see it). When enabled it carries `destructiveHint: true` so MCP
126
- // hosts can require explicit user confirmation before each invocation.
127
- if (process.env.BROWSER_AUTOMATION_ALLOW_EVAL === '1') {
128
- server.registerTool('browser_evaluate', // eslint-disable-line @typescript-eslint/quotes
129
- {
130
- description: 'Execute JavaScript in the page context and return the result. ' +
131
- 'DESTRUCTIVE: this is equivalent to running arbitrary code with the privileges of the current page; ' +
132
- 'hosts SHOULD require user confirmation before each call. ' +
133
- 'Only registered when BROWSER_AUTOMATION_ALLOW_EVAL=1 is set.',
134
- inputSchema: {
135
- script: z.string().describe('JavaScript code to execute'),
136
- },
137
- annotations: {
138
- readOnlyHint: false,
139
- destructiveHint: true,
140
- idempotentHint: false,
141
- openWorldHint: true,
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
@@ -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,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
@@ -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.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",