fix(cli,federation): exclusive, unpredictable staging for every atomic write - #154
Conversation
…c write Widen the two Codex fixes (instructions.rs, hooks_settings.rs) to the whole class: every "stage a sibling temp, rename over the destination" site used a PID-derived name inside a directory the analyzed repository controls, so a committed symlink at that name turned the staging write into a write-through (`fs::write` and SQLite both follow symlinks). New `atomic_fs` helpers create the staging entry with O_CREAT|O_EXCL on a random name via `tempfile`, keep it in the destination's directory so the rename stays atomic, and request 0o666 so the resulting mode follows the umask exactly as `fs::write` did (the tempfile default of 0600 would have made CLAUDE.md / settings.json unreadable to other users of a shared checkout). Sites: instructions (CLAUDE.md/AGENTS.md), .claude/settings.json, .mcp.json, the store .gitignore, `loomweave db backup`, the skill-pack directory swap (staging and backup slots), and the federation `ephemeral.port` marker (tempfile promoted to a runtime dependency of loomweave-federation). Regression tests plant the old predictable name as a symlink to a victim file at each site and assert the victim is untouched and the destination is a regular file; the helper's own test pins umask parity with `fs::write`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0183ccEmYnCh5Nx2Y6weYfHy
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Pull request overview
This PR hardens all remaining “atomic write via sibling temp + rename” call sites by switching from predictable PID-derived staging names to exclusive, unpredictable staging entries (via tempfile) to prevent symlink write-through and pre-planted path attacks across CLI and federation.
Changes:
- Introduces
crates/loomweave-cli/src/atomic_fs.rshelpers for exclusive staging and atomic replacement, with umask-parity permissions on Unix. - Migrates multiple atomic-write sites (instructions, hooks settings,
.mcp.json, store.gitignore, DB backup, skill-pack swap, federation ephemeral port) to use exclusive randomized staging. - Adds targeted Unix regression tests that plant the legacy predictable staging symlinks and assert the victim is not modified.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| crates/loomweave-federation/src/loomweave_port.rs | Uses exclusive tempfile staging for ephemeral.port writes + adds symlink regression test. |
| crates/loomweave-federation/Cargo.toml | Promotes tempfile to a runtime dependency for federation. |
| crates/loomweave-cli/src/skill_pack.rs | Replaces PID-named staging/backup dirs with exclusive temp dirs for crash-safe pack swaps. |
| crates/loomweave-cli/src/mcp_registration.rs | Switches .mcp.json atomic write to atomic_fs::replace_file + adds symlink regression test. |
| crates/loomweave-cli/src/main.rs | Wires in the new atomic_fs module. |
| crates/loomweave-cli/src/instructions.rs | Switches instruction atomic writes to exclusive NamedTempFile staging + adds symlink regression test. |
| crates/loomweave-cli/src/install.rs | Uses atomic_fs::replace_file for store .gitignore + adds symlink regression test. |
| crates/loomweave-cli/src/hooks_settings.rs | Uses atomic_fs::replace_file for .claude/settings.json + adds symlink regression test. |
| crates/loomweave-cli/src/db.rs | Uses exclusive staging for SQLite backup output, preventing symlink write-through + adds regression test. |
| crates/loomweave-cli/src/atomic_fs.rs | New shared implementation for exclusive staging + atomic replace, with tests. |
| crates/loomweave-cli/Cargo.toml | Promotes tempfile to a runtime dependency for the CLI. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| pub(crate) fn replace_file(dest: &Path, prefix: &str, bytes: &[u8]) -> Result<()> { | ||
| let dir = dest.parent().unwrap_or_else(|| Path::new(".")); | ||
| std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?; |
Rebase of Codex #145 (instructions.rs) and #150 (hooks_settings.rs) onto
release/1.6.0, plus a third commit widening the fix to the whole class.Problem
Every "write a sibling temp, then rename over the destination" site staged at a PID-derived name (
<file>.tmp-<pid>) inside a directory the analyzed repository controls. A committed symlink at that name turned the staging write into a write-through to wherever it pointed:fs::writefollows symlinks, and so does SQLite (db backup). #145/#150 fixed two of seven sites.Fix
crates/loomweave-cli/src/atomic_fs.rs:staging_file_in/staging_dir_in/replace_file—O_CREAT|O_EXCLon a random name (tempfile), staged in the destination's own directory (rename stays same-filesystem atomic),0o666requested so the kernel applies the umask and the resulting mode is exactly whatfs::writeproduced. Thetempfiledefault (0600) would silently have made an installedCLAUDE.md/settings.jsonunreadable to other users of a shared checkout — that is the one behavioural correction over fix(cli): prevent symlink attacks during instruction writes #145/fix(cli): harden hook settings atomic write #150..claude/settings.json,.mcp.json, store.gitignore,db backup(staging path handed to SQLite), skill-pack swap (both the staging and the backup slot are exclusive dirs;rename(2)replaces an empty dir atomically), federationephemeral.port(tempfilepromoted to a runtime dep ofloomweave-federation).tempfilewas already a runtime dep ofloomweave-clion this branch, which is what made fix(cli): prevent symlink attacks during instruction writes #145/fix(cli): harden hook settings atomic write #150 conflict.Tests
Each site plants the old predictable name as a symlink to a victim and asserts: victim untouched, destination a regular file with the expected content, planted link still a link. Helper tests pin no-leak on repeated writes and umask parity with
fs::write. Thedb backuptest spells the store leaf out so the worktree store-path audit does not read it as a runtime resolution site.Gates run locally: fmt, clippy
-D warnings(cli + federation),nextestfor both crates (968 + 155, bins built first),cargo doc -D warnings,cargo deny.Supersedes #145 and #150.
🤖 Generated with Claude Code