@mindstone/mcp-server-browser-automation 0.2.0 → 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,7 +9,7 @@ Browser control you can watch: open pages, sign in, click around, fill forms, ta
9
9
 
10
10
  ## Status
11
11
 
12
- - **Version:** [0.2.0](./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
14
  - **Tools:** [21](./src/tools/) (navigation, observation, interaction, sessions, files)
15
15
  - **Surface:** browser-automation
@@ -117,7 +117,6 @@ 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). |
121
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). |
122
121
 
123
122
  ### MCP Host Configuration
@@ -133,7 +132,7 @@ No API keys or credentials are required. The server communicates with the browse
133
132
  }
134
133
  ```
135
134
 
136
- ## Available Tools (20 by default; +1 when `BROWSER_AUTOMATION_ALLOW_EVAL=1`)
135
+ ## Available Tools (21)
137
136
 
138
137
  ### Navigation
139
138
  - **browser_navigate** — Navigate to a URL
@@ -157,7 +156,7 @@ No API keys or credentials are required. The server communicates with the browse
157
156
  - **browser_select** — Select an option from a dropdown
158
157
  - **browser_hover** — Hover over an element
159
158
  - **browser_upload** — Upload workspace files to a file input (regular files only, staged privately before upload)
160
- - **browser_evaluate** — Execute JavaScript in the page context (gated; see [Security notes](#security-notes))
159
+ - **browser_evaluate** — Execute JavaScript in the page context (on by default; `destructiveHint: true` so hosts can require confirmation — see [Security notes](#security-notes))
161
160
 
162
161
  ### Session Management
163
162
  - **browser_tabs** — List open tabs or switch to a tab
@@ -177,15 +176,9 @@ The typical workflow uses accessibility snapshots for reliable element targeting
177
176
 
178
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.
179
178
 
180
- ### `browser_evaluate` is gated behind `BROWSER_AUTOMATION_ALLOW_EVAL`
179
+ ### `browser_evaluate` runs by default — host confirmation is the gate
181
180
 
182
- `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**:
183
-
184
- ```bash
185
- BROWSER_AUTOMATION_ALLOW_EVAL=1 mcp-server-browser-automation
186
- ```
187
-
188
- 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.
189
182
 
190
183
  ### URL scheme deny-list
191
184
 
@@ -207,7 +200,7 @@ To override the session name (for example, to keep separate profiles per project
207
200
  ### Recommended deployment posture
208
201
 
209
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.
210
- - **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.
211
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.
212
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.
213
206
 
@@ -219,7 +212,7 @@ Both tools are marked `destructiveHint: true`, so hosts can require user confirm
219
212
 
220
213
  Additional hardening:
221
214
 
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.
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.
223
216
  - **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`.
224
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.
225
218
 
@@ -60,6 +60,14 @@ export declare function stageUploadSource(inputPath: string, stagingDir: string,
60
60
  export interface PdfStagingTarget {
61
61
  /** Validated final destination for the PDF. */
62
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;
63
71
  /** Private staging path the CLI should write to. */
64
72
  stagingPath: string;
65
73
  /** Remove with discardStagingDir once installed (or on failure). */
@@ -79,12 +87,22 @@ export declare function createPdfStagingTarget(filePath: string, overwrite: bool
79
87
  /**
80
88
  * Install a staged PDF at its validated destination.
81
89
  *
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.
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.)
88
106
  */
89
- export declare function installStagedFile(stagingPath: string, destPath: string, overwrite: boolean): void;
107
+ export declare function installStagedFile(target: PdfStagingTarget, overwrite: boolean): void;
90
108
  //# sourceMappingURL=path-safety.d.ts.map
@@ -233,9 +233,20 @@ export function createPdfStagingTarget(filePath, overwrite) {
233
233
  if (!overwrite && fs.existsSync(resolved.path)) {
234
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
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
+ }
236
246
  const stagingDir = createStagingDir('browser-pdf-');
237
247
  return {
238
248
  destPath: resolved.path,
249
+ canonicalParentDir,
239
250
  stagingPath: path.join(stagingDir, sanitiseBasename(resolved.path, 'page.pdf')),
240
251
  stagingDir,
241
252
  };
@@ -243,14 +254,25 @@ export function createPdfStagingTarget(filePath, overwrite) {
243
254
  /**
244
255
  * Install a staged PDF at its validated destination.
245
256
  *
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.
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.)
252
273
  */
253
- export function installStagedFile(stagingPath, destPath, overwrite) {
274
+ export function installStagedFile(target, overwrite) {
275
+ const { stagingPath, destPath, canonicalParentDir } = target;
254
276
  let staged;
255
277
  try {
256
278
  staged = fs.lstatSync(stagingPath);
@@ -261,12 +283,21 @@ export function installStagedFile(stagingPath, destPath, overwrite) {
261
283
  if (!staged.isFile()) {
262
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.');
263
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
+ }
264
298
  if (overwrite) {
265
299
  fs.rmSync(destPath, { force: true });
266
300
  }
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
301
  try {
271
302
  fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
272
303
  }
@@ -119,32 +119,27 @@ WORKFLOW: browser_snapshot → find input @ref → browser_fill`,
119
119
  }));
120
120
  // M3.12 — `browser_evaluate` lets the model run arbitrary JavaScript inside
121
121
  // the page context, which is the security equivalent of giving it a shell
122
- // on whatever site it has just navigated to. To prevent prompt-injected
123
- // content from doing this silently, the tool is registered ONLY when the
124
- // host explicitly opts in via `BROWSER_AUTOMATION_ALLOW_EVAL=1`. Without
125
- // that env var the tool is not in the tools list at all (the LLM cannot
126
- // even see it). When enabled it carries `destructiveHint: true` so MCP
127
- // hosts can require explicit user confirmation before each invocation.
128
- if (process.env.BROWSER_AUTOMATION_ALLOW_EVAL === '1') {
129
- server.registerTool('browser_evaluate', // eslint-disable-line @typescript-eslint/quotes
130
- {
131
- description: 'Execute JavaScript in the page context and return the result. ' +
132
- 'DESTRUCTIVE: this is equivalent to running arbitrary code with the privileges of the current page; ' +
133
- 'hosts SHOULD require user confirmation before each call. ' +
134
- 'Only registered when BROWSER_AUTOMATION_ALLOW_EVAL=1 is set.',
135
- inputSchema: {
136
- script: z.string().describe('JavaScript code to execute'),
137
- },
138
- annotations: {
139
- readOnlyHint: false,
140
- destructiveHint: true,
141
- idempotentHint: false,
142
- openWorldHint: true,
143
- },
144
- }, withErrorHandling(async (args) => {
145
- const result = await execAgentBrowser(['eval', args.script]);
146
- return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
147
- }));
148
- }
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
+ }));
149
144
  }
150
145
  //# sourceMappingURL=interaction.js.map
@@ -115,14 +115,15 @@ The file is written inside the workspace directory (MCP_WORKSPACE_PATH, or the s
115
115
  openWorldHint: true,
116
116
  },
117
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.
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.
122
123
  const target = createPdfStagingTarget(args.file_path, args.overwrite);
123
124
  try {
124
125
  await execAgentBrowser(['pdf', target.stagingPath]);
125
- installStagedFile(target.stagingPath, target.destPath, args.overwrite);
126
+ installStagedFile(target, args.overwrite);
126
127
  return JSON.stringify({ ok: true, file_path: target.destPath });
127
128
  }
128
129
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstone/mcp-server-browser-automation",
3
- "version": "0.2.0",
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",