Repository navigation
filesystem: a Windows-style path is silently accepted on POSIX and becomes a literal filename inside the allowed root #4686
Description
Describe the bug
On a POSIX host, passing a Windows-style path such as C:\Users\me\notes\file.md to write_file does not fail. It succeeds, and the server answers Successfully wrote to C:\Users\me\notes\file.md.
What actually happens is that a single file is created directly inside the allowed root, whose filename is the entire string, backslashes included:
/Users/me/C:\Users\me\notes\file.md
This is not a containment failure — nothing escapes the sandbox, and isPathWithinAllowedDirectories is doing its job. It is the absence of a rejection for a string that can only mean something on another OS. The caller believes it wrote to a Windows location; what exists is an unpronounceable file at the root of the allowed directory, and the success message gives no hint. In our case the file sat unnoticed for about three weeks.
Why it happens
In src/filesystem/lib.ts:
export async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: resolveRelativePathAgainstAllowedDirectories(expandedPath);Under POSIX, path.isAbsolute("C:\\Users\\me\\notes\\file.md") is false. The string therefore takes the relative branch, and resolveRelativePathAgainstAllowedDirectories does path.resolve(allowedDir, thatString). The result lands inside the allowed root, so isPathWithinAllowedDirectories legitimately approves it, and the write proceeds.
The same reasoning applies to C:/Users/me/... — there the directory tree C:/Users/... is created inside the allowed root instead.
To Reproduce
- Run the server on macOS or Linux with a single allowed directory, e.g.
/tmp/sandbox. - Call
write_filewithpath: "C:\\Users\\me\\notes\\file.md"and any content. - Observe the response:
Successfully wrote to C:\Users\me\notes\file.md. ls /tmp/sandboxshows one entry namedC:\Users\me\notes\file.md.
Expected behavior
validatePath should refuse a string that matches a Windows drive-letter prefix (^[A-Za-z]:[\\/], and the bare ^[A-Za-z]:$) when process.platform !== 'win32', instead of silently treating it as a relative path. An explicit error — something like Access denied - Windows-style path received on a POSIX host — turns a silent, late-discovered mistake into an immediate one.
A narrower alternative, if outright rejection is considered too strict: keep accepting it but never let a drive-letter string reach the relative branch, and say so in the response.
Please be careful not to reintroduce #3628
That report is the mirror image of this one: a Linux path like /h/user/data was converted into H:\user\data. The fix proposed here is deliberately one-directional and platform-guarded — reject the drive-letter form, only when not running on Windows, and never rewrite POSIX paths.
Environment
@modelcontextprotocol/server-filesystem— reproduced against the code currently onmain(src/filesystem/lib.ts, thevalidatePath/resolveRelativePathAgainstAllowedDirectoriespair quoted above).- Also reproduced against the shipped Claude Desktop Filesystem extension, version
2026.7.10, on macOS with Node v24.19.0.
Activity
CryoThrust commentedon Aug 28, 2026
I can prepare a focused platform-validation fix for this. I will verify the current path resolution on POSIX, reject drive-letter forms before the relative-path branch, and add tests that preserve normal POSIX paths and Windows behavior.
olaservo commentedon Aug 28, 2026
Confirmed against current
main. On POSIXpath.isAbsolute('C:\\Users\\...')is false, so the string takes the relative branch invalidatePathand resolves inside the first allowed directory. Nothing escapes, but the caller is told the write went somewhere it did not.Rejecting a drive-letter prefix (
/^[A-Za-z]:([\\/]|$)/) whenprocess.platform !== 'win32'is the right fix. A file literally namedC:at the root of an allowed directory is the only thing that check would break, and I am fine with that trade.@CryoThrust go ahead. Keep it to
validatePathinsrc/filesystem/lib.tsplus a vitest case insrc/filesystem/__tests__/lib.test.tsthat skips onwin32. #4638 and #4631 both touched this function this week, so base on currentmain.