fix(filesystem): resolve symlinked allowed directories to both forms#3254
Conversation
On macOS, /tmp is a symlink to /private/tmp. When users specify /tmp as an allowed directory, the server was resolving it to /private/tmp during startup but then rejecting paths like /tmp/file.txt because they dont start with /private/tmp. This fix stores BOTH the original normalized path AND the resolved path in allowedDirectories, so users can access files through either form. For example, with /tmp as allowed directory, both /tmp/file.txt and /private/tmp/file.txt will now be accepted. Fixes modelcontextprotocol#3253
olaservo
left a comment
There was a problem hiding this comment.
Review: APPROVE with suggestions
Correct fix for a real macOS usability bug. The approach of storing both the original symlink path and the resolved realpath in allowedDirectories is sound and security-neutral.
Bug analysis
The root cause is in validatePath() (lib.ts:108-111): it checks the lexically-normalized request path against allowedDirectories before calling fs.realpath(). When startup only stores the resolved form (/private/tmp), a request for /tmp/file.txt fails the pre-realpath gate and never reaches the second check.
Storing both forms fixes this cleanly at the source — the startup resolution step — without touching the runtime validation logic.
Verified
validatePath()pre-realpath gating (lib.ts:108-111) confirmedisPathWithinAllowedDirectoriesis pure sync prefix-match, no fs I/OnormalizePathis purely lexical- Security properties unchanged: both stored paths resolve to the same physical directory; symlink-target validation in
validatePath()remains intact - No conflict with #3229, #3238, or #3205
Suggestions (non-blocking)
-
Integration test gap: The test in
path-validation.test.tsmanually constructsallowedDirsWithBoth— it testsisPathWithinAllowedDirectoriesgiven correct input, but doesn't verify the startup code inindex.tsactually produces both forms. Consider adding a symlink case tostartup-validation.test.tsthat spawns the server with a symlinked directory argument. -
Code comment on catch block: When a directory doesn't exist at startup, only the unresolved path is stored. If it later appears as a symlink, the mismatch could recur for that dir. A brief comment noting this acceptable tradeoff would help future maintainers.
Disclosure: This review was conducted with assistance from Claude Code (claude-opus-4-6). All claims were verified against the source files.
Summary
Fixes #3253
Problem
On macOS,
/tmpis a symlink to/private/tmp. When users specify/tmpas an allowed directory:/tmpto/private/tmpand stores only/private/tmpin allowedDirectories/tmp/file.txt, the initial validation check fails because/tmpdoesn't match/private/tmpSolution
Store both the original normalized path andthe resolved path in
allowedDirectories. This allows users to access files through either form:/tmp/file.txtmatches/tmpin allowedDirectories/private/tmp/file.txtmatches/private/tmpin allowedDirectoriesChanges
src/filesystem/index.ts: Modified allowed directory initialization to store both original and resolved paths when they differsrc/filesystem/__tests__/path-validation.test.ts: Added test for the symlink behaviorTests
Reproduction
Before fix:
npx -y @modelcontextprotocol/server-filesystem /tmp # Then request /tmp/test.txt -> REJECTEDAfter fix: