Skip to content

filesystem: write_file/edit_file destroy file creation time (birthtime) and file identity due to atomic-rename write strategy #4512

Description

@DannyAmmon

Summary

write_file and edit_file replace existing files via a temp-file + fs.rename pattern (writeFileContent and applyFileEdits in src/filesystem/lib.ts). There is a security rationale in the code comments: atomic rename prevents symlink race conditions (TOCTOU) between path validation and write. However, this strategy has an undocumented side effect: every write to an existing file creates a new inode, which

  • destroys the file's creation timestamp (birthtime on macOS/APFS and BSD, creation time on Windows/NTFS, crtime on ext4), and
  • breaks file identity: hard links are severed, and inode-based file watchers/editors tracking the file lose it.

Notably, the server itself treats the creation timestamp as first-class metadata: get_file_info returns it as a dedicated created: field. Reporting this field but silently destroying it on each of its own write operations is internally inconsistent.

To reproduce

  1. Create a file, note birthtime (e.g. stat -f "%SB" file.md on macOS, or via the server's own get_file_info).
  2. Call edit_file (or write_file) on it.
  3. Read birthtime again → it now equals the time of the edit.

(move_file correctly preserves birthtime, since rename keeps the inode.)

Root cause

writeFileContent falls back to temp-file + rename for existing files; applyFileEdits uses the same pattern unconditionally. rename replaces the target with a different file, so all creation metadata and identity of the original are lost.

Suggested fix

Preserve the symlink-safety property while writing in place:

  1. fs.open(filePath, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW) — fails with ELOOP if the path is a symlink, giving the same protection the rename pattern provides (no writes through symlinks swapped in after validation).
  2. fstat the handle and verify it is a regular file (defense in depth; also allows re-checking the resolved path if desired).
  3. ftruncate + write through the handle → same inode, birthtime, hard links, and watchers all preserved.

On POSIX platforms (macOS, Linux, BSD) O_NOFOLLOW is well supported. On Windows, symlink creation requires elevated privileges by default and the threat model differs; the current rename path could be retained there, or FILE_FLAG_OPEN_REPARSE_POINT semantics used.

Trade-off: In-place writes lose crash-atomicity (a crash mid-write can leave a partially written file, which the rename pattern avoids). If that property is considered essential, an alternative would be an opt-in mode (env var or tool argument, e.g. preserveFileIdentity) selecting the in-place strategy — or at minimum, documenting the metadata-destroying behavior of the current implementation in the tool descriptions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions