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

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.2](./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,8 +212,8 @@ 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.
223
- - **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`.
215
+ - **Staged file hand-off.** Validated files are never re-opened by pathname. `browser_upload` sources are opened once (`O_NOFOLLOW` + `O_NONBLOCK`, so a post-validation leaf-symlink swap fails and a planted FIFO cannot wedge the open), verified to be regular files (directories, devices, and FIFOs are refused), bound to a fresh confined resolution by device+inode (an intermediate-directory swap after validation redirects the resolution and is refused with `UPLOAD_SOURCE_CHANGED`), 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`. Even then, a destination that is a directory is refused (`DESTINATION_IS_DIRECTORY`) — the overwrite delete is a bare unlink and never recurses.
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
 
226
219
  ## Licence
@@ -1,3 +1,4 @@
1
+ import * as fs from 'node:fs';
1
2
  export type ResolveResult = {
2
3
  ok: true;
3
4
  path: string;
@@ -49,17 +50,35 @@ export declare function discardStagingDir(stagingDir: string): void;
49
50
  * the CLI should consume.
50
51
  *
51
52
  * 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
53
+ * ONCE with O_NOFOLLOW — a leaf swapped for a symlink between validation
54
+ * and open fails with ELOOP instead of being followed outside the
55
+ * workspace — and O_NONBLOCK, so a planted FIFO cannot wedge the connector
56
+ * by blocking the open until a writer appears. The fd is fstat-verified to
57
+ * be a regular file (directories, FIFOs, sockets, and devices pass
58
+ * realpathSync but must not become upload payloads), then bound to a fresh
59
+ * confined resolution of the path by dev+inode — an intermediate directory
60
+ * (or, on platforms without O_NOFOLLOW, the leaf itself) swapped for a
61
+ * symlink after validation redirects the resolution to a different inode
62
+ * and is refused. The content is streamed through the same fd into a
63
+ * private staging slot carrying over only the requested basename, so the
56
64
  * CLI never re-opens the validated pathname and a post-validation swap of
57
65
  * the original cannot redirect the upload.
66
+ *
67
+ * `openFile` is injectable for adversarial tests only; production callers
68
+ * use the default `fs.openSync`.
58
69
  */
59
- export declare function stageUploadSource(inputPath: string, stagingDir: string, index: number): string;
70
+ export declare function stageUploadSource(inputPath: string, stagingDir: string, index: number, openFile?: typeof fs.openSync): string;
60
71
  export interface PdfStagingTarget {
61
72
  /** Validated final destination for the PDF. */
62
73
  destPath: string;
74
+ /**
75
+ * Canonical realpath of destPath's parent directory, pinned at validation
76
+ * time (the parent is created before the CLI runs). installStagedFile
77
+ * re-verifies it before writing, so an intermediate directory swapped to
78
+ * a symlink during the CLI call is refused instead of followed outside
79
+ * the workspace.
80
+ */
81
+ canonicalParentDir: string;
63
82
  /** Private staging path the CLI should write to. */
64
83
  stagingPath: string;
65
84
  /** Remove with discardStagingDir once installed (or on failure). */
@@ -79,12 +98,22 @@ export declare function createPdfStagingTarget(filePath: string, overwrite: bool
79
98
  /**
80
99
  * Install a staged PDF at its validated destination.
81
100
  *
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.
101
+ * Two defences, one per path component class:
102
+ * - Leaf: the install never writes through a pre-existing filesystem
103
+ * entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
104
+ * already at the destination — including a planted symlink. With
105
+ * `overwrite`, the old entry is removed first and the same
106
+ * exclusive-create follows, so a leaf swap planted in between surfaces
107
+ * as an EEXIST refusal instead of a write through an attacker-chosen
108
+ * path.
109
+ * - Intermediate directories: exclusive-create guards only the final
110
+ * component, so the parent's canonical identity (pinned at validation,
111
+ * before the CLI ran) is re-verified immediately before installing. A
112
+ * directory swapped to a symlink during the CLI call is refused rather
113
+ * than followed outside the workspace — for the write and, with
114
+ * `overwrite`, for the preceding delete. (A swap landing after this
115
+ * re-check but before the copy is a sub-millisecond residual window, on
116
+ * par with any local check-then-use race.)
88
117
  */
89
- export declare function installStagedFile(stagingPath: string, destPath: string, overwrite: boolean): void;
118
+ export declare function installStagedFile(target: PdfStagingTarget, overwrite: boolean): void;
90
119
  //# sourceMappingURL=path-safety.d.ts.map
@@ -170,19 +170,37 @@ function sanitiseBasename(p, fallback) {
170
170
  const base = path.basename(p);
171
171
  return base === '' || base === '.' || base === '..' ? fallback : base;
172
172
  }
173
+ /**
174
+ * A post-validation swap raced the upload source. Distinct from
175
+ * PATH_OUTSIDE_WORKSPACE: the path WAS inside the workspace at validation
176
+ * time, so retrying can succeed.
177
+ */
178
+ function uploadSourceChangedError(inputPath) {
179
+ return new ConnectorError(`file_path changed while it was being validated: ${inputPath}`, 'UPLOAD_SOURCE_CHANGED', 'Retry the call, or pass a path to a regular file inside the workspace directory.');
180
+ }
173
181
  /**
174
182
  * Stage one `browser_upload` source into `stagingDir` and return the path
175
183
  * the CLI should consume.
176
184
  *
177
185
  * 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
186
+ * ONCE with O_NOFOLLOW — a leaf swapped for a symlink between validation
187
+ * and open fails with ELOOP instead of being followed outside the
188
+ * workspace — and O_NONBLOCK, so a planted FIFO cannot wedge the connector
189
+ * by blocking the open until a writer appears. The fd is fstat-verified to
190
+ * be a regular file (directories, FIFOs, sockets, and devices pass
191
+ * realpathSync but must not become upload payloads), then bound to a fresh
192
+ * confined resolution of the path by dev+inode — an intermediate directory
193
+ * (or, on platforms without O_NOFOLLOW, the leaf itself) swapped for a
194
+ * symlink after validation redirects the resolution to a different inode
195
+ * and is refused. The content is streamed through the same fd into a
196
+ * private staging slot carrying over only the requested basename, so the
182
197
  * CLI never re-opens the validated pathname and a post-validation swap of
183
198
  * the original cannot redirect the upload.
199
+ *
200
+ * `openFile` is injectable for adversarial tests only; production callers
201
+ * use the default `fs.openSync`.
184
202
  */
185
- export function stageUploadSource(inputPath, stagingDir, index) {
203
+ export function stageUploadSource(inputPath, stagingDir, index, openFile = fs.openSync) {
186
204
  const resolved = resolveWorkspaceReadPath(inputPath);
187
205
  if (!resolved.ok) {
188
206
  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).');
@@ -190,12 +208,58 @@ export function stageUploadSource(inputPath, stagingDir, index) {
190
208
  const slot = path.join(stagingDir, String(index));
191
209
  fs.mkdirSync(slot, { mode: STAGING_DIR_MODE });
192
210
  const stagingPath = path.join(slot, sanitiseBasename(resolved.path, `upload-${index}`));
193
- const inFd = fs.openSync(resolved.path, 'r');
211
+ // O_NOFOLLOW: resolved.path is canonical (no symlinks at validation
212
+ // time), so a symlink at the leaf here can only be a post-validation
213
+ // swap — refuse instead of following it outside the workspace.
214
+ // O_NONBLOCK: opening a FIFO O_RDONLY would otherwise block until a
215
+ // writer appears, letting a planted pipe wedge the connector; for
216
+ // regular files O_NONBLOCK is a no-op. (Either flag is undefined on
217
+ // platforms without it, where the composition degrades to a plain
218
+ // O_RDONLY — the dev+inode re-resolution below still refuses a swap
219
+ // there.)
220
+ let inFd;
221
+ try {
222
+ inFd = openFile(resolved.path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
223
+ }
224
+ catch (err) {
225
+ const code = err.code;
226
+ if (code === 'ENOENT') {
227
+ throw new ConnectorError(`File not found: ${inputPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Pass file paths inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset).');
228
+ }
229
+ if (code === 'ELOOP' || code === 'EMLINK' || code === 'EFTYPE' ||
230
+ code === 'EISDIR' || code === 'EPERM' || code === 'EACCES') {
231
+ // ELOOP/EMLINK/EFTYPE: the validated leaf was swapped for a symlink
232
+ // before we could open it (the O_NOFOLLOW errno varies by platform).
233
+ // EISDIR/EPERM/EACCES: swapped for a directory or an unreadable
234
+ // object. Either way, refuse rather than read.
235
+ throw uploadSourceChangedError(inputPath);
236
+ }
237
+ throw err;
238
+ }
194
239
  try {
195
240
  const stat = fs.fstatSync(inFd);
196
241
  if (!stat.isFile()) {
197
242
  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
243
  }
244
+ // An intermediate directory swapped for a symlink after validation
245
+ // could have redirected the open itself (O_NOFOLLOW constrains only
246
+ // the final component), so bind the descriptor to a fresh confined
247
+ // resolution: the path must still resolve inside the workspace to the
248
+ // SAME dev+inode we opened. Bytes are read through the fd below, so a
249
+ // swap landing after this check cannot change the uploaded content.
250
+ const recheck = resolveWorkspaceReadPath(inputPath);
251
+ let fresh;
252
+ if (recheck.ok) {
253
+ try {
254
+ fresh = fs.statSync(recheck.path);
255
+ }
256
+ catch {
257
+ fresh = undefined; // resolution failed — refuse below
258
+ }
259
+ }
260
+ if (!fresh || fresh.dev !== stat.dev || fresh.ino !== stat.ino) {
261
+ throw uploadSourceChangedError(inputPath);
262
+ }
199
263
  const outFd = fs.openSync(stagingPath, 'wx', STAGING_FILE_MODE);
200
264
  try {
201
265
  const chunk = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
@@ -233,9 +297,20 @@ export function createPdfStagingTarget(filePath, overwrite) {
233
297
  if (!overwrite && fs.existsSync(resolved.path)) {
234
298
  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
299
  }
300
+ // Create the parent directory NOW, before the multi-second CLI call, and
301
+ // pin its canonical identity. resolved.path is canonical, so the realpath
302
+ // of the freshly prepared parent must equal it exactly; a mismatch means
303
+ // a component changed under us and the destination cannot be trusted.
304
+ const parentDir = path.dirname(resolved.path);
305
+ fs.mkdirSync(parentDir, { recursive: true });
306
+ const canonicalParentDir = fs.realpathSync(parentDir);
307
+ if (canonicalParentDir !== parentDir) {
308
+ 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.');
309
+ }
236
310
  const stagingDir = createStagingDir('browser-pdf-');
237
311
  return {
238
312
  destPath: resolved.path,
313
+ canonicalParentDir,
239
314
  stagingPath: path.join(stagingDir, sanitiseBasename(resolved.path, 'page.pdf')),
240
315
  stagingDir,
241
316
  };
@@ -243,14 +318,25 @@ export function createPdfStagingTarget(filePath, overwrite) {
243
318
  /**
244
319
  * Install a staged PDF at its validated destination.
245
320
  *
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.
321
+ * Two defences, one per path component class:
322
+ * - Leaf: the install never writes through a pre-existing filesystem
323
+ * entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
324
+ * already at the destination — including a planted symlink. With
325
+ * `overwrite`, the old entry is removed first and the same
326
+ * exclusive-create follows, so a leaf swap planted in between surfaces
327
+ * as an EEXIST refusal instead of a write through an attacker-chosen
328
+ * path.
329
+ * - Intermediate directories: exclusive-create guards only the final
330
+ * component, so the parent's canonical identity (pinned at validation,
331
+ * before the CLI ran) is re-verified immediately before installing. A
332
+ * directory swapped to a symlink during the CLI call is refused rather
333
+ * than followed outside the workspace — for the write and, with
334
+ * `overwrite`, for the preceding delete. (A swap landing after this
335
+ * re-check but before the copy is a sub-millisecond residual window, on
336
+ * par with any local check-then-use race.)
252
337
  */
253
- export function installStagedFile(stagingPath, destPath, overwrite) {
338
+ export function installStagedFile(target, overwrite) {
339
+ const { stagingPath, destPath, canonicalParentDir } = target;
254
340
  let staged;
255
341
  try {
256
342
  staged = fs.lstatSync(stagingPath);
@@ -261,12 +347,40 @@ export function installStagedFile(stagingPath, destPath, overwrite) {
261
347
  if (!staged.isFile()) {
262
348
  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
349
  }
350
+ const parentDir = path.dirname(destPath);
351
+ let currentParentDir;
352
+ try {
353
+ currentParentDir = fs.realpathSync(parentDir);
354
+ }
355
+ catch (err) {
356
+ const reason = err instanceof Error ? err.message : String(err);
357
+ 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.');
358
+ }
359
+ if (currentParentDir !== canonicalParentDir) {
360
+ 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.');
361
+ }
264
362
  if (overwrite) {
265
- fs.rmSync(destPath, { force: true });
363
+ // unlinkSync, not rmSync: only a regular file or symlink may ever be
364
+ // removed here. unlink refuses directories atomically in the kernel
365
+ // (no lstat pre-check to race, as rmSync's ERR_FS_EISDIR path has) and
366
+ // can never recurse — deleting a directory tree as a PDF-overwrite
367
+ // side effect would be far worse than refusing.
368
+ try {
369
+ fs.unlinkSync(destPath);
370
+ }
371
+ catch (err) {
372
+ const code = err.code;
373
+ if (code === 'EISDIR' || code === 'EPERM') {
374
+ // EISDIR on Linux, EPERM on macOS: the destination is a directory.
375
+ throw new ConnectorError(`Destination is a directory, not a file: ${destPath}`, 'DESTINATION_IS_DIRECTORY', 'Choose a file_path that does not collide with an existing directory.');
376
+ }
377
+ if (code !== 'ENOENT') {
378
+ throw err;
379
+ }
380
+ // ENOENT: nothing to overwrite — the exclusive-create below installs
381
+ // the staged file.
382
+ }
266
383
  }
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
384
  try {
271
385
  fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
272
386
  }
@@ -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
@@ -105,7 +105,7 @@ Use this for reading and summarising page content — it returns clean text, unl
105
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
106
  inputSchema: {
107
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.'),
108
+ overwrite: z.boolean().optional().default(false).describe('Replace an existing file at file_path. Default: false — existing files are refused. A directory at file_path is refused even with overwrite: true.'),
109
109
  },
110
110
  annotations: {
111
111
  readOnlyHint: false,
@@ -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.2",
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",