@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 +3 -3
- package/dist/path-safety.d.ts +16 -5
- package/dist/path-safety.js +90 -7
- package/dist/tools/observation.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
package/dist/path-safety.d.ts
CHANGED
|
@@ -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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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;
|
package/dist/path-safety.js
CHANGED
|
@@ -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
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|