@mindstone/mcp-server-browser-automation 0.2.1 → 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.1](./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
@@ -212,8 +212,8 @@ Both tools are marked `destructiveHint: true`, so hosts can require user confirm
212
212
 
213
213
  Additional hardening:
214
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`.
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.
217
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.
218
218
 
219
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,14 +50,24 @@ 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;
@@ -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);
@@ -296,7 +360,26 @@ export function installStagedFile(target, overwrite) {
296
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.');
297
361
  }
298
362
  if (overwrite) {
299
- 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
+ }
300
383
  }
301
384
  try {
302
385
  fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstone/mcp-server-browser-automation",
3
- "version": "0.2.1",
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",