Repository navigation
fix(filesystem): compare overwrite identity as BigInt stats, with a Windows no-device fallback - #5325
Conversation
…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>
🦋 Changeset detectedLatest commit: 8a40eb2 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
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
…it reaches the identity check on Windows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
|
Copilot round 1: one finding, fixed.
Requesting round 2. |
There was a problem hiding this comment.
🔵 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
|
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 |
…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>
…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>

Closes #5154
Description
write_fileandedit_fileoverwrite an existing file in place, and before writing they check that the handle they opened is the very file the path'slstatsaw (the TOCTOU guard). That check compareddevandinofrom number stats. The thread on #5154 established two problems with it:inoas a number rounds. NTFS file ids are 64-bit, and a number stat'sinois a double, so ids above 2^53 round (measured on Node 20 and 24 in the thread:57139420272459647read as57139420272459650). Two different files could compare equal, which is the check failing open. This affects supported Node lines.dev0 while the handle reports the volume serial, so every overwrite of an existing file was refused.The change, in
isSameEntry(pinned-directory.ts):lstatSync/fstatSyncwith{ bigint: true }inopenInDirectory, andfs.lstat(…, { bigint: true })inlib.ts'soverwriteInPlace), soinocompares exactly.inomust always match.devmust match too, as before.devis0n, the device is treated as unknown, not as a match: the file must then also agree onsizeandmtimeNs, and any difference refuses. The check is never skipped; a stale or changed size or time fails closed. (mtimeNsexists 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-latestleg checks the premise on each run: for a workspace file, the path stat'sdevis nonzero and equals the handle's, and the two agree onino. 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
write_fileandedit_file's in-place overwrite (pinned-directory.tsisSameEntry/openInDirectory/overwriteFileInDirectory, andlib.tsoverwriteInPlace'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 (
enginesis>=24), but the rounding ofinois 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, newthe identity check (#5154)block, on the real filesystem):inoof 2^53 against a handle reporting 2^53 + 1 (one number as doubles) refuses;dev: 0n: overwrites when inode, size and mtime match; creates a new file whose path stat reports no device;dev: 0n: refuses when the inode, the size or the mtime differs (one case each);dev: 0nstill refuses;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 andlib.test.tsfixtures moved to BigInt stats; the type change makes a mixed number/BigInt comparison a compile error, so a fixture cannot silently refuse because1n !== 1.Verified only by mocks or CI: this machine is macOS. The Windows branch is exercised by stubbing
process.platform(the existingwin32pattern) with a forceddev: 0n; the real-volume premise test runs only on thewindows-latestCI 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_fileto create,write_fileagain to overwrite, thenedit_filev2→v3: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-configagainst 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 backv3 from llmwith its inode unchanged (290769306).npm run local:gate: exit 0.Breaking Changes
None. No client configuration changes.
Types of changes
Checklist
npm run changeset) if this changes what a TypeScript server publishes (patch)Additional context
Shared function, sibling #5188: this changes
isSameEntry's signature (both arguments are nowPick<fs.BigIntStats, "dev" | "ino" | "size" | "mtimeNs">) andopenInDirectorynow returnsopened: fs.BigIntStats. #5188's PR #5322 addsmoveFileAcrossDevices, which callsisSameEntrywith 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