fix(subagent): never write the state .gitignore through a link

Hardens 491d3df (keep .codewhale/state out of the user's git status).
That commit writes the file with `if !ignore.exists() { let _ =
fs::write(..) }`. exists() is false for a dangling symlink, and
fs::write follows it. A cloned repository could ship
.codewhale/state/.gitignore as a link to a path outside the workspace,
such as ~/.config/git/ignore. Opening it in Codewhale would then create
that file containing "*", and git would ignore every new file the
person makes in every repository.

The write now goes through the module's own guard:
reject_root_relative_symlinks refuses a linked leaf, and
OpenOptions::create_new (O_EXCL) never follows a link that appears after
the check. AlreadyExists is a no-op, as before. 491d3df's choice that a
failed write never fails the coordination lock is kept, but the failure
is now logged as a warning on the subagent target instead of discarded.

Evidence, from `cargo build -p codewhale-cli --bin codewhale` at this
commit, starting `codewhale app-server --http` with an isolated HOME,
CODEWHALE_HOME and runtime dir:
- Clean git repo: .codewhale/state holds .gitignore ("*") and
  subagents.v1.lock, and `git status --short` prints nothing.
- Repo that commits .codewhale/state/.gitignore as a dangling link to
  <tmp>/outside/ignore: subagents.v1.lock is created and the coordination
  lock is taken, and <tmp>/outside stays empty. git then lists the lock,
  because the repo replaced the ignore file with a link.
- The 491d3df hole at the OS level: for a dangling link, exists() is
  False, and an open-for-write through the link creates the target with
  "*\n" (python3 -I check).

Not run: the codewhale-tui lib test binary is OOM-killed in this
container (memcg, 13.8 GB RSS), so the coordination tests are left to
CI. cargo fmt --check passes. The warning is not visible in app-server
stderr, because no subscriber prints the subagent target there.

Refs CW-7214

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JQ5oApYVLjkFD3kNAc3yRt
This commit is contained in:
Claude committed 2026-10-11 13:12:43 +00:00
1 parent 7be1f48a12
commit b1e39f4758
1 file changed
+13 -2
+13 -2
View File
@@ -3669,8 +3669,19 @@ impl CoordinationProcessLock {
reject_root_relative_symlinks(&requested_root, &lock_dir.join(SUBAGENT_STATE_LOCK_FILE))?;
fs::create_dir_all(&lock_dir)?;
let ignore = lock_dir.join(".gitignore");
if !ignore.exists() {
let _ = fs::write(&ignore, "*\n");
let ignored = reject_root_relative_symlinks(&requested_root, &ignore).and_then(|()| {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&ignore)
{
Ok(mut file) => file.write_all(b"*\n").map_err(Into::into),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(error.into()),
}
});
if let Err(error) = ignored {
tracing::warn!(target: "subagent", %error, "could not write {}", ignore.display());
}
// Creating a missing root can change its canonical spelling on
// Windows (for example by adding a `\\?\` prefix). Re-resolve both