Repository navigation
fix(filesystem): allow a client Root in its advertised form as well as its realpath - #5317
Conversation
🦋 Changeset detectedLatest commit: b7471ab The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
🟡 Changes recommended
Advertised-path normalization can strip legal filename characters and authorize an unrelated directory outside the resolved Root.
1 open issue
What changed in this PR
This PR addresses #5183 by allowing filesystem client Roots in both their advertised and resolved forms, matching command-line directory handling.
Changes:
- Preserve and deduplicate both Root path forms.
- Add protocol-level tests for alias access, symlink escapes, macOS
/tmp, and deduplication. - Document the behavior and add a patch changeset.
| File | Description |
|---|---|
src/filesystem/roots-utils.ts |
Retains both path forms for valid Roots. |
src/filesystem/README.md |
Documents alias access and directory listings. |
src/filesystem/__tests__/server-roots.test.ts |
Covers dual-form access, escape rejection, and deduplication. |
.changeset/5183-filesystem-root-advertised-form.md |
Records the filesystem patch release change. |
Review effort: Balanced
a857a50 to
40a28a8
Compare
|
Copilot round 1: one finding, fixed.
Also rebased onto |
| const stats: Stats = await fs.stat(resolvedPath); | ||
| if (stats.isDirectory()) { | ||
| validatedDirectories.push(resolvedPath); | ||
| validatedDirectories.add(advertised); |
There was a problem hiding this comment.
Fixed in 60f3cd4. Confirmed first with a failing protocol-level test: after the Roots load, the Root alias is retargeted to a directory outside it, and write_file under the alias succeeded there. resolveUnicodeEquivalentPath (lib.ts) now checks the allowed directory's own realpath against the allowed directories before walking the missing tail, as it already did for every component below it, so write_file and create_directory under a retargeted alias are refused and nothing is created outside. The test covers both tools.
|
Copilot round 2: one finding, fixed.
This touches
|
|
Copilot round 3 (on 60f3cd4): no new comments. The review's one listed open item is the round-2 thread ("Prevent aliased file creation outside configured roots"), which is still unresolved only because resolving is the reviewer's act; it was fixed in 60f3cd4 and answered in its thread. Its headline asks for human confirmation that advertised aliases keep the resolved-directory boundary: the tests pinning that boundary are the symlink-escape, trimmed-sibling and retargeted-alias cases in Treating this as a clean round: the loop ends here. Over to a maintainer. |
…s its realpath parseRootUri kept only the realpath of each client Root, so a request that used the Root's own spelling (a symlinked directory, /tmp on macOS) was refused. It now returns both forms and getValidRootDirectories admits both, deduplicated, as resolveAllowedDirectories does for command-line directories. validatePath still realpaths every request, so a symlink inside a Root that points outside it stays refused. Closes #5183 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
normalizePath trims whitespace and quotes, which are legal POSIX filename characters, so a Root advertised as 'alias ' would have allowed an unrelated sibling 'alias'. Format the advertised form with normalizePathFormat instead, and pin it with a regression test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
…creating under it A Root's advertised form can be a symlink, which can be retargeted after the Roots load. resolveUnicodeEquivalentPath started its walk for a missing path from the allowed directory's realpath without checking it, so write_file or create_directory under a retargeted alias landed outside every allowed directory. Check that starting realpath like each component below it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
60f3cd4 to
b7471ab
Compare
|
Rebased onto
No logic changed beyond adopting the merged helpers, so no new Copilot round. |

Closes #5183
Description
A client Root is now allowed in the form the client advertised it as well as in its resolved (realpath) form, deduplicated, which is how
resolveAllowedDirectories(index.ts) already treats command-line directories.parseRootUri(roots-utils.ts) returns both the absolute path as advertised (formatted withnormalizePathFormat, never trimmed, since trimming would name a different directory) and the normalized realpath of a Root, instead of the realpath alone. The pre-existing trimming of the realpath itself is filesystem: allowed directories are trimmed after realpath, so a name ending in a space or quote allows its sibling #5320.getValidRootDirectoriesstats the resolved path once, as before, and admits both forms of each Root that is a directory, deduplicated across Roots (a Root advertised in both spellings is listed once per spelling).resolveUnicodeEquivalentPath(lib.ts, the missing-path branch ofvalidatePath) now checks the allowed directory's own realpath against the allowed directories before appending a missing tail. An advertised form can be a symlink retargeted after the Roots load; without this,write_file/create_directoryunder it landed outside (Copilot round 2). This also covers a symlinked command-line directory.Access stays bounded by the resolved directories:
validatePathstill realpaths every request and re-checks it against the allowed list, so a symlink inside a Root that points outside it is refused under either spelling (pinned by a new test).The two-forms logic is written in
roots-utils.tsrather than extracted into a helper shared withindex.ts: #5185 is changingresolveAllowedDirectories' realpath handling in parallel, and a shared helper would put both PRs in the same hunk. The two now behave the same; folding them into one helper can follow once #5185 lands.Server Details
roots-utils.ts), the missing-path check inlib.ts, solist_allowed_directoriesand every path-taking tool on a 2025-era connection with Roots. README (Roots section,list_allowed_directories).Motivation and Context
On macOS
/tmpis a symlink to/private/tmp. A Root offile:///tmp/projectwas allowed only as/private/tmp/project, so a request for/tmp/project/x(the Root's own spelling) was refused with "Access denied", although the same directory given on the command line works under either spelling. The same applies to any symlinked project directory, and per the issue to a Windows mapped drive that resolves to a UNC path.Shared functions: this changes
parseRootUriandgetValidRootDirectories, which #5185 also touches (parseRootUri's realpath/retry behavior), and adds one block tolib.tsresolveUnicodeEquivalentPath, onvalidatePath's ENOENT path, which #5185 and #4162 also work in. It does not touch the roots update flow inserver.ts(#3602) orindex.ts.Prior work: #4145 by @nnish16 and #4275 by @yyzquwu, closed in the backlog triage (#4875), were the prototypes for this fix. Thank you to both.
How Has This Been Tested?
Tests (protocol level,
__tests__/server-roots.test.ts), written first and failing onv2/main:[link, target], andread_text_filesucceeds through both spellings;/tmp/...is listed as/tmp/...and/private/tmp/..., and a read through/tmp/...succeeds (it.runIf(darwin));alias(trailing space, a symlink) does not allow an unrelated siblingalias: the advertised form is kept verbatim (normalizePathFormat), not trimmed (Copilot round 1; POSIX only);write_fileandcreate_directoryunder it are refused, and nothing is created outside (Copilot round 2).The existing "resolve a symlinked root to its target" test asserted the old single-form behavior and is replaced by the first of these.
Inspector CLI 2.12.0, with Roots supplied through the config file's per-server
rootssetting. Server started with command-line directory/tmp/mcp5183/cliand Rootfile:///tmp/mcp5183/root(/tmp→/private/tmp);root/note.txtis a file,root/escape.txta symlink tocli/secret.txt. Before =v2/main'sroots-utils, after = this branch.2025-11-25 (
--protocol-era legacy), where Roots apply:list_allowed_directories/private/tmp/mcp5183/root/tmp/mcp5183/root,/private/tmp/mcp5183/rootread_text_file /tmp/mcp5183/root/note.txtAccess denied - path outside allowed directories: /tmp/mcp5183/root/note.txt not in /private/tmp/mcp5183/rootinside the rootread_text_file /private/tmp/mcp5183/root/note.txtinside the rootinside the rootread_text_file /tmp/mcp5183/root/escape.txtAccess denied - path outside allowed directories: …Access denied - symlink target outside allowed directories: /private/tmp/mcp5183/cli/secret.txt not in /tmp/mcp5183/root, /private/tmp/mcp5183/root2026-07-28 (
--protocol-era modern,_metacarriesio.modelcontextprotocol/serverInfo): Roots are never requested on this era, so before and after are identical:list_allowed_directoriesreturns the command-line directory in both forms (/tmp/mcp5183/cli,/private/tmp/mcp5183/cli), and every read under the Root is refused as outside it.LLM client: Claude Code 2.1.311 (
claude -p, Sonnet, local build via--strict-mcp-config), prompt "call list_allowed_directories and then read_text_file on /tmp/mcp5183/root/note.txt" with/tmp/mcp5183/rooton the command line →Allowed directories: /tmp/mcp5183/root /private/tmp/mcp5183/root,inside the root. Claude Code negotiates 2026-07-28 and has no era switch, so it never sends Roots: this run shows the command-line path unchanged, and the Roots change itself was exercised only through the Inspector CLI on 2025-11-25 and the tests.Windows: the mapped-drive case in the issue is not verified on a real Windows machine; it is covered only by the
windows-latestCI leg running the same suites.npm run local:gate: exit 0.Breaking Changes
None. A Root's resolved form is still allowed; its advertised form is now allowed too.
list_allowed_directorieslists a symlinked Root twice, once per form, as it already did for a symlinked command-line directory.Types of changes
Checklist
npm run changeset) if this changes what a TypeScript server publishes (patch)🤖 Generated with Claude Code