Skip to content

Commit b3310ab

Browse files
cliffhallclaude
andcommitted
docs(filesystem): read-only files are refused by the in-place write (#4512)
Writing in place opens the file itself for writing, so a read-only file now fails with EACCES (EPERM on Windows) where the old rename replaced it whenever its directory was writable. Keep that as the behavior: say so in the README and changeset and pin it with a test. Also correct a test comment that credited overwriteInPlace with a refusal validatePath makes. Both found by Copilot review. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent 7dc802f commit b3310ab

4 files changed

Lines changed: 31 additions & 4 deletions

File tree

‎.changeset/filesystem-in-place-write.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@modelcontextprotocol/server-filesystem": patch
33
---
44

5-
`write_file` and `edit_file` now overwrite an existing file in place instead of renaming a temp file over it, so the file keeps its inode, creation time (birthtime), hard links and permission bits (#4512), and an overwrite no longer fails with `EPERM` on Windows when another process holds the file open (#3199). A symlink swapped in after the path was validated is still refused rather than written through, now by an `O_NOFOLLOW` open (on POSIX) and a check that the opened file is the one validated (on every platform). The overwrite is no longer crash-atomic.
5+
`write_file` and `edit_file` now overwrite an existing file in place instead of renaming a temp file over it, so the file keeps its inode, creation time (birthtime), hard links and permission bits (#4512), and an overwrite no longer fails with `EPERM` on Windows when another process holds the file open (#3199). A symlink swapped in after the path was validated is still refused rather than written through, now by an `O_NOFOLLOW` open (on POSIX) and a check that the opened file is the one validated (on every platform). The overwrite is no longer crash-atomic. A read-only file is now refused (`EACCES`, or `EPERM` on Windows) instead of being replaced, since writing in place opens the file itself for writing.

‎src/filesystem/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ The server's directory access control follows this flow:
9898
- An existing file is overwritten in place, so it keeps its inode, creation
9999
time, hard links and permissions (`edit_file` writes the same way). The
100100
overwrite is not crash-atomic: a crash mid-write can leave the file partly
101-
written
101+
written. A read-only file is refused rather than replaced
102102

103103
- **edit_file**
104104
- Make selective edits using advanced pattern matching and formatting

‎src/filesystem/__tests__/path-validation.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,8 +1275,11 @@ describe("Path Validation", () => {
12751275
const realPath = await fs.realpath(legitFile);
12761276
expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
12771277

1278-
// The server's write path refuses a symlink rather than following it
1279-
// (lstat, O_NOFOLLOW and an inode check in lib.ts's overwriteInPlace)
1278+
// So validatePath, which checks the realpath, refuses this
1279+
// outside-pointing symlink before any write is attempted. A symlink
1280+
// swapped in after validation is refused by the write itself (lstat,
1281+
// O_NOFOLLOW and an inode check in lib.ts's overwriteInPlace), which
1282+
// server-tools.test.ts covers.
12801283

12811284
// Verify content remains unchanged
12821285
const targetContent = await fs.readFile(targetFile, "utf-8");

‎src/filesystem/__tests__/server-tools.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,30 @@ describe("write_file", () => {
521521
expect(await fs.readFile(hardLink, "utf-8")).toBe("changed");
522522
});
523523

524+
// Writing in place opens the existing file for writing, so a read-only file
525+
// is refused rather than replaced; the old rename only got past the mode
526+
// bits because it rewrote the directory entry instead of the file. Root
527+
// ignores mode bits, so the refusal cannot be observed there.
528+
it.skipIf(process.getuid?.() === 0)(
529+
"refuses to overwrite a read-only file and leaves it unchanged",
530+
async () => {
531+
const file = path.join(dir, "readonly.txt");
532+
await fs.writeFile(file, "keep me");
533+
await fs.chmod(file, 0o444);
534+
try {
535+
const result = await call(client, "write_file", {
536+
path: file,
537+
content: "replaced",
538+
});
539+
expect(result.isError).toBe(true);
540+
expect(textOf(result)).toMatch(/EACCES|EPERM/);
541+
expect(await fs.readFile(file, "utf-8")).toBe("keep me");
542+
} finally {
543+
await fs.chmod(file, 0o644);
544+
}
545+
},
546+
);
547+
524548
it("truncates the old content when the new content is shorter", async () => {
525549
const file = path.join(dir, "existing.txt");
526550
await fs.writeFile(file, "a much longer original");

0 commit comments

Comments
 (0)