Skip to content

fix(installer): a rewritten config keeps its mode, so a 0600 file stays private - #2779

Open
colbymchenry wants to merge 1 commit into
mainfrom
claude/installer-keep-file-mode
Open

colbymchenry wants to merge 1 commit into
mainfrom
claude/installer-keep-file-mode

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

Every installer write goes to <path>.tmp.<pid> and is then renamed over the file. That is atomicWriteFileSync, which every agent target uses, and since #2721 it also writes through symbolic links this way.

The rename puts a new file in the old one's place, and a new file gets the default mode (0666 minus the umask, usually 0644) and the current process's owner. So on macOS and Linux, a ~/.claude.json or ~/.codex/config.toml the user had made 0600 came back 0644, readable by every account on the machine. Both files can hold tokens, for example in another MCP server's env. It happened on codegraph install, on install --refresh (which codegraph upgrade runs) and on uninstall.

I found this while fixing #2721. There is no issue for it.

The fix

writeThenRename, behind atomicWriteFileSync, now:

  1. Reads the stats of the file it replaces. Only a regular file counts; when writing through a link, it is the file at the end of the link.
  2. Opens the temp file with the old file's permission bits (mode & 0o777). The umask can only narrow those, so the temp is never readable by more people than the old file, not even before the rename.
  3. Before any content goes in:
    • Gives the temp the old owner and group when they differ (fchown).
    • Sets the exact old mode (fchmod, mode & 0o7777). This comes after the owner change, because changing the owner can clear the set-id bits.
  4. Treats both of those as best effort. Only root may give a file away, and only a member may set a file's group. If either change is refused, or the file system has no modes, the write still goes ahead with what step 2 set.

A file that doesn't exist yet is created exactly as before: 0666 minus the umask, with no owner or mode changes.

Calls I made

  • The owner is kept where the system allows it. As a normal user, a refused change leaves this account's owner, as today. Under sudo, a user's config now keeps its owner instead of becoming root's.
  • A failed fchown or fchmod never fails an install. The mode the temp file is opened with already caps its permissions at the old file's.
  • Windows runs the same code. It has no POSIX owner, and its only mode is the read-only attribute, which already makes the rename fail (EPERM). That is unchanged, and checked below.
  • Codegraph's own files are left alone. The telemetry config, daemon pid files and viewer trail store are written by their own temp-and-rename code. They are not user configs, so their behaviour is unchanged.

Validation

This machine is Windows, which has no POSIX modes, and it has no Docker or WSL.

The logic, on this machine. __tests__/installer-file-mode.test.ts reports a POSIX mode and owner (0600, uid 4242, gid 4343) through a mocked fs.statSync. It records every call made on each temp file and expects open 600 → chown 4242:4343 → chmod 600 → write → close → rename onto the right file. It covers:

  • the helper itself
  • a simulated link
  • a refused chown and chmod
  • a new file, which gets the default mode and no chown or chmod
  • every agent and location, through install and uninstall, with the user's own 0600 files
  • a refresh rewriting stale ~/.claude.json and ~/.codex/config.toml entries
  • the Windows read-only attribute

24 of its tests run here. On main's helper, 22 fail, all on assertions. The other 2 guard behaviour that stays the same. All 24 pass with the fix.

Real modes, POSIX only. These tests are gated with it.runIf(process.platform !== 'win32') and did not run here:

  • 8 in the new file:
    • 0600, 0640, 0444 and 0664 files keep their mode
    • the temp file is already 0600 when it is renamed
    • a real symbolic link keeps its target's mode
    • a new file gets the same mode a plain write gives it
    • the group is kept when the account is in a second group
  • 19 in installer-targets.test.ts:
    • for every agent and location, 0600 files stay 0600 through install and uninstall
    • a refresh keeps a 0600 ~/.claude.json and config.toml

The new file runs the same flows on this machine with the mode mocked, so only the reads of real modes go unexercised here. They will run on macOS.

The real CLI, against a private home with 7 seeded 0600 configs:

  1. codegraph install --target claude,codex,opencode,hermes,gemini --location global --yes
  2. install --refresh --yes, after making two entries stale
  3. uninstall … --keep-cli

Windows has no modes, so POSIX mode rules were simulated by a --require preload (posix-modes.cjs, in the kit):

  • a new file gets its mode minus a 022 umask
  • chmod and fchmod set the mode
  • a rename carries the mode to the new name
  • copyFile copies the mode
step main this PR
install all 7 → 644 all 7 stay 600
install --refresh (stale entries) 644 600
uninstall 644 600

The user's own settings survive on both, and no temp file is left behind.

Windows read-only. codegraph install --target codex with a read-only ~/.codex/config.toml gives the same result on main and with this PR. Both refuse with EPERM … rename, the file is unchanged and still read-only, and no temp file is left. The existing #2721 test for a link to a read-only file also passes.

Type-check. The touched test files add no new type errors.

Full suite on the pushed head 3c5f0c7 (Windows 11, CODEGRAPH_KERNEL_EXPECT=1 npx vitest run --maxWorkers=4 --minWorkers=1): 701 files passed, 6 skipped (707); 8455 tests passed, 109 skipped, 0 failed. The 27 POSIX-only tests are among the skipped.

🤖 Generated with Claude Code

…ys private

Every installer write goes to <path>.tmp.<pid> and is renamed over the
file, through a symbolic link too since the fix for #2721. A new file
gets the default mode and this process's owner, so on macOS and Linux
a 0600 ~/.claude.json or ~/.codex/config.toml, which can hold tokens,
came out 0644 after install, refresh or uninstall: readable by every
account on the machine. Found while fixing #2721.

The temp file now takes the old file's mode and owner. It is opened
with the old permission bits, which the umask can only narrow. Then,
before any content is written, it gets the old owner (fchown, where
this account may set it) and the exact old mode (fchmod). A refused
owner change, or a file system without modes, doesn't fail the write.
The write-through-link path uses the same code, so a linked file keeps
its mode. A file that doesn't exist yet still gets the default mode.

Validation (this machine is Windows, which has no POSIX modes):
- installer-file-mode.test.ts reports a mode and owner through a
  mocked fs.statSync and records what is done to the temp file: the
  helper, a simulated link, a refused chown and chmod, a new file, and
  every agent's install and uninstall, plus a refresh. Of its 24 tests
  that run here, 22 fail on main's helper (all on assertions; the other
  2 guard what stays the same) and all 24 pass with the fix.
- The POSIX tests (real chmod and stat: 8 in the new file, 19 in
  installer-targets.test.ts) are gated with it.runIf and did not run
  here.
- The real CLI (install, install --refresh, uninstall) against a
  private home, with POSIX mode rules simulated by a --require preload:
  main turned all 7 seeded 0600 configs into 0644, the fix kept all 7
  at 0600 through every step.
- On Windows a read-only config.toml is refused with the same EPERM on
  both, left unchanged, with no temp file left beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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