Skip to content

[Bug]: writeFileStringAtomically widens a file's mode on replace and is not crash-durable #14967

Open

Description

@samkelly880

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Create a file the server later rewrites through writeFileStringAtomically (for example a settings file), and chmod 600 it.
  2. Make a change that causes the server to save that file.
  3. ls -l the file.

Separately, for durability: a power loss or kernel panic right after one of these writes can leave the file empty or stale on ext4.

Expected behavior

An atomic replace keeps the target's existing permission bits (or uses a restrictive default for a new file), and it survives a crash: write temp → fsync temp → rename → fsync the directory.

Actual behavior

writeFileStringAtomically (apps/server/src/atomicWrite.ts) writes the temp file with fs.writeFileString (:22) and renames it over the target (:23), with no other steps:

  • Mode is not preserved. The temp file is created at the default 0o666 & ~umask (typically 0644), and the rename replaces the target's inode. A file that was 0600 comes back 0644 after the next save.
  • Not crash-durable. Neither the temp file nor the parent directory is fsynced. The rename can reach disk before the data, so a crash can leave a zero-length or stale file.

The atomicity itself is fine (same-directory rename, random mkdtemp directory). The issue is mode preservation and durability. auth/ServerSecretStore.ts already uses the right pattern (explicit mode + sync).

Impact

Minor bug or occasional failure

Version or commit

main @ c0995d2

Environment

Linux (ext4); found by code reading and reproduced in a fork (T3o).

Logs or stack traces

No response

Workaround

No response


A reference fix from our fork: atomicWrite.ts at bd87f4d. It copies the target's mode (0600 when the file is new), fsyncs the temp file, and fsyncs the directory after the rename.

Activity

  1. juliusmarminge commented on Oct 3, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Triage

    Thanks for the careful report and the reference fix, @samkelly880! The mode bug is real and still present on main; apps/server/src/atomicWrite.ts is unchanged since c0995d2.

    writeFileStringAtomically writes a temp file with FileSystem.writeFileString and renames it over the target without passing a mode. Node's fs.writeFile uses 0o666 masked by the process umask, and rename replaces the destination inode, so the previous mode is lost. With umask 022, a 0600 file comes back 0644 after the next save. We reproduced that sequence: the new contents landed, and only the mode changed.

    Nothing in this repo sets a mode on the files this helper writes (settings.json, keybindings.json, server-runtime.json, the provider status cache, and the ACP sign-in flag). A file created here is already 0644 under a typical umask, so the regression is a later save undoing a mode the user tightened. That matters because settings.json can hold some secrets in plaintext (providers.opencode.serverPassword, the Antigravity apiKey). Provider environment secrets, Bitbucket tokens, and usage-limit management keys go to the secret store first, so they aren't in this file. Since ~/.t3/userdata is created with the default mkdir mode (0755 at umask 022), a file that flips back to 0644 is readable by any local user who can traverse the home directory.

    The crash-durability part is only half right. The helper never fsyncs the temp file or the parent directory, so the rename is atomic for concurrent readers but not power-fail durable. On default ext4 (data=ordered with auto_da_alloc), this replace-via-rename pattern is specifically handled so a crash doesn't leave a zero-length file; the usual result is the previous contents, which is the normal cost of skipping fsync. server-runtime.json is already ignored when its pid is dead, and the cache and ACP-auth readers tolerate a missing file.

    One small correction: ServerSecretStore isn't quite the pattern described. Its set (the replace path) chmods 0600 before and after the rename but doesn't sync. create opens with mode 0600 and calls file.sync, but it's an exclusive create rather than a replace, and it doesn't fsync the directory.

    No other issue tracks this. A fix that preserves the existing permission bits, and uses 0600 when the file doesn't exist yet, fits these callers. Note that Node's stat.mode includes the file-type bits (0o100644), so the chmod needs to mask to 0o777. Directory fsync would be separate hardening rather than a required part of this fix.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.securityvia-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions