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

openedOct 3, 2026bySsamkelly880

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.

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.

Fresh triage: this is a file-mode/durability bug report, not a demonstrated exploitable vulnerability under the updated security rule. No duplicate found among open issues within 90 days. On main at b5a226e49c52ecab4ddc2d678a7012d97e651a6a, the helper now lives in packages/shared/src/atomicWrite.ts and still writes the temporary file before renaming without mode preservation or fsync. This is source inspection only; runtime reproduction was not performed. Routing to Julius because the proposed fix changes file permissions, which the automated triage flow explicitly excludes. No fix or PR created.

Reproduced on Linux at 3b95e7831b with the real dev server and web client using isolated state. After chmod 600 .t3/userdata/settings.json, turning off Claude in Settings → Providers saved the setting and changed the file mode to 0664 (process umask 0002). General settings are now database-backed and did not exercise this writer. No duplicate among 880 open issues checked in the 90-day window. Power-loss behavior was not simulated.

Fixed in #24310, opened on GitHub and labeled bugfix. The real dev-server save now keeps a 0600 settings file at 0600 under umask 0002. The integrated branch passes 150 focused tests and shared typecheck. Native Windows/macOS and actual power-loss behavior were not tested.