Skip to content

fix(filesystem): compare overwrite identity as BigInt stats, with a Windows no-device fallback - #5325

Merged
cliffhall merged 2 commits into
v2/mainfrom
v2/fix/5154-filesystem-bigint-identity
Oct 27, 2026
Merged

cliffhall merged 2 commits into
v2/mainfrom
v2/fix/5154-filesystem-bigint-identity

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #5154

Description

write_file and edit_file overwrite an existing file in place, and before writing they check that the handle they opened is the very file the path's lstat saw (the TOCTOU guard). That check compared dev and ino from number stats. The thread on #5154 established two problems with it:

  1. ino as a number rounds. NTFS file ids are 64-bit, and a number stat's ino is a double, so ids above 2^53 round (measured on Node 20 and 24 in the thread: 57139420272459647 read as 57139420272459650). Two different files could compare equal, which is the check failing open. This affects supported Node lines.
  2. On Windows with libuv < 1.51.0 (the Node 23 line), a path-based stat reports dev 0 while the handle reports the volume serial, so every overwrite of an existing file was refused.

The change, in isSameEntry (pinned-directory.ts):

  • Both sides are now BigInt stats (lstatSync/fstatSync with { bigint: true } in openInDirectory, and fs.lstat(…, { bigint: true }) in lib.ts's overwriteInPlace), so ino compares exactly.
  • ino must always match.
  • Normally dev must match too, as before.
  • Only on win32, and only when the path stat's dev is 0n, the device is treated as unknown, not as a match: the file must then also agree on size and mtimeNs, and any difference refuses. The check is never skipped; a stale or changed size or time fails closed. (mtimeNs exists only on BigInt stats, which is one more reason for the switch.)

This is the narrowest version I could find that is sound: no Node-version parsing (the condition is the observed fact, and is inert on every line where the path stat reports a device), no fallback when a nonzero device differs, and no degradation to inode-only when both sides report 0.

The thread's proposed CI assertion is added to the filesystem suite, skipped off Windows, so the windows-latest leg checks the premise on each run: for a workspace file, the path stat's dev is nonzero and equals the handle's, and the two agree on ino. If a supported Node ever reports no device for a path, the fallback would become the normal path, and this test says so.

Server Details

  • Server: filesystem
  • Changes to: the identity check behind write_file and edit_file's in-place overwrite (pinned-directory.ts isSameEntry / openInDirectory / overwriteFileInDirectory, and lib.ts overwriteInPlace's lstat). No tool schema, output or README text changes.

Motivation and Context

#5154. The dev-0 refusal only reaches users on the unsupported Node 23 line (engines is >=24), but the rounding of ino is live on supported Node on Windows, and the comparison it feeds is a security check.

How Has This Been Tested?

Unit tests (__tests__/pinned-directory.test.ts, new the identity check (#5154) block, on the real filesystem):

  • inodes compare exactly: an expected ino of 2^53 against a handle reporting 2^53 + 1 (one number as doubles) refuses;
  • win32 stubbed, path stat dev: 0n: overwrites when inode, size and mtime match; creates a new file whose path stat reports no device;
  • win32 stubbed, path stat dev: 0n: refuses when the inode, the size or the mtime differs (one case each);
  • win32 stubbed: a known device that differs still refuses;
  • off Windows: a path stat reporting dev: 0n still refuses;
  • real Windows only (skipped elsewhere): the path stat and the handle agree on a nonzero device and on the inode.

Of these, the two "overwrites/creates with dev 0n on win32" cases fail against v2/main (as do the existing overwrite tests once their fixtures are BigInt stats). The existing pinned-directory and lib.test.ts fixtures moved to BigInt stats; the type change makes a mixed number/BigInt comparison a compile error, so a fixture cannot silently refuse because 1n !== 1.

Verified only by mocks or CI: this machine is macOS. The Windows branch is exercised by stubbing process.platform (the existing win32 pattern) with a forced dev: 0n; the real-volume premise test runs only on the windows-latest CI leg (Node 24). No run on Node 23 / Windows was possible here.

Inspector CLI (@modelcontextprotocol/inspector@2.9.0, built branch, macOS, Node 24), in both eras (--protocol-era modern, 2026-07-28; --protocol-era legacy, 2025-11-25): write_file to create, write_file again to overwrite, then edit_file v2 → v3:

== modern
write_file (create)    exit=0  "Successfully wrote to …/modern.md"   inode before: 290765460
write_file (overwrite) exit=0  "Successfully wrote to …/modern.md"
edit_file              exit=0  "```diff …"
inode after: 290765460  content: v3 overwritten
== legacy
write_file (create)    exit=0  "Successfully wrote to …/legacy.md"   inode before: 290766711
write_file (overwrite) exit=0  "Successfully wrote to …/legacy.md"
edit_file              exit=0  "```diff …"
inode after: 290766711  content: v3 overwritten

On macOS the same commands also succeed on v2/main (the defect is Windows-only), so this shows the overwrite path still works and keeps the inode with BigInt stats, not the Windows fix itself.

LLM client: Claude Code 2.1.311, claude -p --strict-mcp-config against the built branch. The era could not be selected from this client. Prompt: "Using the filesystem server, overwrite the existing file …/notes.md with the content 'v2 from llm', then use edit_file to change 'v2' to 'v3' in it." Result: Successfully wrote to …/notes.md, then the edit diff; the file read back v3 from llm with its inode unchanged (290769306).

npm run local:gate: exit 0.

Breaking Changes

None. No client configuration changes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation
  • My changes follow MCP security best practices (the TOCTOU check never fails open; the fallback is stricter than the device-only skip proposed earlier in the thread)
  • I have updated the server's README accordingly (not applicable: no tool input, output or documented behavior changes)
  • I have added a changeset (npm run changeset) if this changes what a TypeScript server publishes (patch)
  • I have tested this with an LLM client
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have documented all environment variables and configuration options (not applicable: none added)

Additional context

Shared function, sibling #5188: this changes isSameEntry's signature (both arguments are now Pick<fs.BigIntStats, "dev" | "ino" | "size" | "mtimeNs">) and openInDirectory now returns opened: fs.BigIntStats. #5188's PR #5322 adds moveFileAcrossDevices, which calls isSameEntry with number stats; whichever of the two merges second passes { bigint: true } there. Nothing else in #5188's area is touched.

A known limit, accepted: on the Node 23 line, if Windows' path-based stat returned a stale size or mtime for a file, the fallback would refuse that overwrite rather than allow it. That is the fail-closed direction the thread asked for.

🤖 Generated with Claude Code

…indows no-device fallback

The identity check between the path lstat and the opened handle's fstat
now takes both as BigInt stats, so NTFS file ids above 2^53 compare
exactly. On win32, a path stat reporting dev 0n (libuv < 1.51.0) is
treated as an unknown device: inode, size and mtimeNs must then all
match, and any difference refuses. A windows-latest-only test pins the
premise that a supported Node reports a nonzero device that agrees with
the handle.

Closes #5154

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 label Oct 27, 2026
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a40eb2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/server-filesystem Patch

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new inode-precision test loses a required stat method in its mock, causing the Windows suite to fail before reaching the intended assertion.

1 open issue
What changed in this PR

This PR addresses #5154 by making the filesystem server’s overwrite identity checks compare file IDs exactly and handle Windows path stats that report no device.

Changes:

  • Use BigInt stats for identity checks during file creation and overwrite.
  • Add a Windows-only fallback requiring matching inode, size and modification time when the path device is unknown.
  • Update test fixtures, add regression coverage and record a patch changeset.
File Description
src/​filesystem/​pinned-directory.ts Implements BigInt identity checks and the Windows fallback.
src/​filesystem/​lib.ts Requests BigInt stats before overwriting and documents the checks.
src/​filesystem/​__tests__/​pinned-directory.test.ts Adds precision, fallback and Windows device-consistency tests.
src/​filesystem/​__tests__/​lib.test.ts Updates overwrite fixtures and assertions for BigInt stats.
.changeset/​5154-filesystem-bigint-identity.md Records the filesystem patch release note.

Review effort: Balanced

Comment thread src/filesystem/__tests__/pinned-directory.test.ts Outdated
…it reaches the identity check on Windows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: one finding, fixed.

  • Preserve lstat prototype in Windows regression mock (__tests__/pinned-directory.test.ts): accepted, a defect in a test this PR added. On Windows the one-shot spread mock was consumed by openInDirectory's symlink pre-check and lost isSymbolicLink. Fixed in 8a40eb2 with persistent, prototype-preserving mocks; checked with process.platform stubbed to win32. Gate green.

Requesting round 2.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive Windows fallback changes identity verification and warrants human validation because the affected runtime was not directly exercised.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 2: clean (no open issues; the round-1 finding shows as resolved). The loop ends on this first clean round.

Copilot flagged the Windows fallback for human validation because no Node 23 / Windows runtime exercised it. That matches what the PR body says: the fallback is covered by tests with the platform stubbed, and only the premise test (on Node 24, path and handle agree on a nonzero device and inode) runs on the real windows-latest leg.

@cliffhall
cliffhall merged commit 1366120 into v2/main Oct 27, 2026
43 checks passed
@cliffhall
cliffhall deleted the v2/fix/5154-filesystem-bigint-identity branch October 27, 2026 12:34
cliffhall added a commit that referenced this pull request Oct 27, 2026
…ss the copy is in place

Review round 1 on #5322:
- the copy is created 0600 and takes the source's mode only once it is
  complete (openInDirectory gains an optional creation mode, default
  unchanged), so a private file is never readable at the destination
- before removing the source, the copy must still be at the destination
  (anchored lstat identity); if it was removed or replaced, the source
  stays and the move fails
- a failure closing the source after a complete copy now removes the
  copy too, so a retry is not blocked by a leftover destination

Rebased onto #5325 (#5154): the cross-device move's identity checks take
BigInt stats in isSameEntry's (pathStat, handleStat) order, and the
copy's identity is read again from its descriptor after the write, since
isSameEntry's Windows dev-0 fallback compares size and mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
cliffhall added a commit that referenced this pull request Oct 27, 2026
…ss the copy is in place

Review round 1 on #5322:
- the copy is created 0600 and takes the source's mode only once it is
  complete (openInDirectory gains an optional creation mode, default
  unchanged), so a private file is never readable at the destination
- before removing the source, the copy must still be at the destination
  (anchored lstat identity); if it was removed or replaced, the source
  stays and the move fails
- a failure closing the source after a complete copy now removes the
  copy too, so a retry is not blocked by a leftover destination

Rebased onto #5325 (#5154): the cross-device move's identity checks take
BigInt stats in isSameEntry's (pathStat, handleStat) order, and the
copy's identity is read again from its descriptor after the write, since
isSameEntry's Windows dev-0 fallback compares size and mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants