Skip to content

fix(config): atomic write + 0o600 enforcement in persist_current#674

Open
hernandez42 wants to merge 1 commit into
usestrix:mainfrom
hernandez42:fix/atomic-write-v2-1783150700
Open

fix(config): atomic write + 0o600 enforcement in persist_current#674
hernandez42 wants to merge 1 commit into
usestrix:mainfrom
hernandez42:fix/atomic-write-v2-1783150700

Conversation

@hernandez42

Copy link
Copy Markdown

Bug

strix/config/loader.py::persist_current() writes the config file (containing
sensitive env vars like LLM_API_KEY, PERPLEXITY_API_KEY) via
target.write_text() then separately target.chmod(0o600). Between these two
steps the file is on disk with default permissions (0o644), creating a race
window where API keys could be read by other local users.

Fix

Replace the two-line write+chmod with a call to the new atomic_write_secure()
helper that:

  1. Creates a unique tmp file with os.open(..., O_CREAT, mode=0o600)
    mode is enforced atomically at the OS level
  2. Writes payload + fsync() before close — durability guaranteed
  3. os.replace() into final path — atomic rename on both POSIX and Windows
  4. Belt-and-suspenders chmod(0o600) after replace
  5. O_NOFOLLOW on tmp open — blocks symlink-plant pre-attacks

Changes

  • Added import uuid (was missing)
  • Added atomic_write_secure() function
  • Replaced the two write+chmod lines in persist_current() with one
    atomic_write_secure(target, payload) call

Submitted by 璇玑-58 via security audit

Replace write_text+chmod with atomic_write_secure() that:
- Creates tmp file with os.open(mode=0o600) — OS-level atomic create
- fsyncs before rename for durability
- os.replace() for atomic rename (POSIX + Windows)
- Belt-and-suspenders chmod after replace
- O_NOFOLLOW to block symlink-plant attacks

Fixes: race window where API keys could be readable during
the gap between write_text() and chmod().
@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes config persistence to use a secure atomic-write helper. The main changes are:

  • Added uuid for unique temporary config filenames.
  • Added atomic_write_secure() with temp-file write, fsync, replace, and chmod.
  • Updated persist_current() to call the new helper.

Confidence Score: 4/5

The changed config persistence path can crash on Windows and on chmod-limited filesystems.

  • os.O_NOFOLLOW is read unconditionally in a path reached by the Windows release build.
  • The new chmod call is no longer best-effort and can stop startup after the config file is replaced.
  • Symlinked config files now get replaced instead of updated through the link.

strix/config/loader.py

Important Files Changed

Filename Overview
strix/config/loader.py Adds a secure atomic-write helper and routes config persistence through it, with portability and compatibility regressions in the new write path.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
strix/config/loader.py:106
**Windows Flag Is Missing**

When the Windows release build reaches `persist_current()`, this expression reads `os.O_NOFOLLOW`, which is not defined on Windows. The CLI can import the module, but config persistence raises `AttributeError` at startup and the scan never starts.

### Issue 2 of 3
strix/config/loader.py:121
**Chmod Failure Aborts Startup**

The old path saved the config and ignored `OSError` from `chmod`, but this new post-replace chmod is uncaught. On filesystems or mounts that allow the write but reject POSIX mode changes, `persist_current()` now exits the CLI during startup after replacing the file.

### Issue 3 of 3
strix/config/loader.py:119
**Symlinked Config Is Replaced**

When `target` is a symlinked config file, `Path.write_text()` used to update the linked file, but `os.replace()` replaces the symlink entry with a new regular file. A `--config` path or dotfile-managed `~/.strix/cli-config.json` symlink will be silently broken, leaving the original config target stale.

Reviews (1): Last reviewed commit: "fix(config): atomic write + 0o600 in per..." | Re-trigger Greptile

Comment thread strix/config/loader.py
try:
fd = os.open(
tmp_path,
flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Windows Flag Is Missing

When the Windows release build reaches persist_current(), this expression reads os.O_NOFOLLOW, which is not defined on Windows. The CLI can import the module, but config persistence raises AttributeError at startup and the scan never starts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/config/loader.py
Line: 106

Comment:
**Windows Flag Is Missing**

When the Windows release build reaches `persist_current()`, this expression reads `os.O_NOFOLLOW`, which is not defined on Windows. The CLI can import the module, but config persistence raises `AttributeError` at startup and the scan never starts.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread strix/config/loader.py
raise
os.replace(tmp_path, target)
# Belt-and-suspenders: enforce mode in case os.replace inherited anything odd
target.chmod(0o600)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Chmod Failure Aborts Startup

The old path saved the config and ignored OSError from chmod, but this new post-replace chmod is uncaught. On filesystems or mounts that allow the write but reject POSIX mode changes, persist_current() now exits the CLI during startup after replacing the file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/config/loader.py
Line: 121

Comment:
**Chmod Failure Aborts Startup**

The old path saved the config and ignored `OSError` from `chmod`, but this new post-replace chmod is uncaught. On filesystems or mounts that allow the write but reject POSIX mode changes, `persist_current()` now exits the CLI during startup after replacing the file.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread strix/config/loader.py
with contextlib.suppress(OSError):
os.close(fd)
raise
os.replace(tmp_path, target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Symlinked Config Is Replaced

When target is a symlinked config file, Path.write_text() used to update the linked file, but os.replace() replaces the symlink entry with a new regular file. A --config path or dotfile-managed ~/.strix/cli-config.json symlink will be silently broken, leaving the original config target stale.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/config/loader.py
Line: 119

Comment:
**Symlinked Config Is Replaced**

When `target` is a symlinked config file, `Path.write_text()` used to update the linked file, but `os.replace()` replaces the symlink entry with a new regular file. A `--config` path or dotfile-managed `~/.strix/cli-config.json` symlink will be silently broken, leaving the original config target stale.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant