Skip to content

fix(notify): harden init against symlink/TOCTOU writes (#616) - #643

Merged
solomonneas merged 3 commits into
mainfrom
fix/616-init-force-toctou
Aug 1, 2026
Merged

fix(notify): harden init against symlink/TOCTOU writes (#616)#643
solomonneas merged 3 commits into
mainfrom
fix/616-init-force-toctou

Conversation

@solomonneas

Copy link
Copy Markdown
Collaborator

Summary

  • agent-notify init now publishes config through stations/notify/internal/safeio instead of os.Stat + os.WriteFile.
  • Exclusive create uses a same-dir temp, file sync, and hard-link publish; --force uses rename so a symlink at the destination is replaced rather than followed.
  • POSIX parent-directory fsync uses O_DIRECTORY|O_NOFOLLOW, matching the Brigade localio / run_journal patterns ported into notify-only Go code.

Acceptance criteria → tests

Criterion Test
Symlink at target cannot redirect the write TestWriteFile_SymlinkCannotRedirectWithoutForce, TestRun_InitSymlinkCannotRedirectWrite
Replacement between validation and write cannot overwrite another file TestWriteFile_ForceReplaceAfterSwapCannotClobberVictim, TestWriteFile_ForceReplacesSymlinkWithoutTouchingTarget, TestRun_InitForceReplacesSymlinkWithoutTouchingTarget
Existing-file behavior unchanged without --force TestWriteFile_RefusesExistingWithoutForce, TestRun_InitRefusesExistingWithoutForce
Failure messages identify refused target without secret material TestRun_InitRefusesExistingWithoutForce, TestWriteFile_SymlinkCannotRedirectWithoutForce

Test plan

  • brigade work verify run --target . --command "./scripts/verify" --capture brigade-work
    • receipt: 20260801-034048-work-verify-2fd004
  • brigade work verify run --target ./stations/notify --command "go test ./..." --capture brigade-work
    • receipt: 20260801-035217-work-verify-cf0e47 (covers the acceptance tests above; ./scripts/verify does not run the Go notify suite)

Closes #616

Made with Cursor

Publish sample config through same-dir temp + exclusive link or rename,
with POSIX parent fsync, so a raced symlink cannot redirect init --force.

Closes #616

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • coderabbit-review

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: escoffier-labs/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7afa2bf3-3380-41b3-b0e6-5a27207c0c95

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@solomonneas solomonneas self-assigned this Aug 1, 2026
@solomonneas

Copy link
Copy Markdown
Collaborator Author

Grader review: APPROVE-WITH-CHANGES. The core mechanism is right - enforcement at the syscall level (link/rename semantics), bounded refusals, fd-based parent fsync matching the run_journal pattern. Two required changes before merge:

  1. The symlinked-parent refusal is evaluated only AFTER publish (safeio/write.go:69-87): CreateTemp and Rename/Link succeed through a symlinked parent, and with --force the rename can replace a same-named file inside the symlink's target directory before fsyncParent raises. Open the parent dir fd up front with O_DIRECTORY|O_NOFOLLOW and publish relative to it (linkat/renameat), or at minimum run the parent check before publish. Also note the non-unix build (fsync_other.go) never checks the parent at all - acknowledge in a comment or gate.
  2. write.go:65 uses os.Chmod by name, which follows symlinks - the exact TOCTOU class this PR fixes. Use tmp.Chmod (fchmod on the open fd) before Close.

Non-blocking: a fsyncParent failure after a successful non-force Link returns an error although the config was published (next init then says 'already exists'); os.Link fails on hard-link-less filesystems - worth a doc note. Add attack-shaped tests for the two required changes (symlinked parent pre-created, and chmod target swapped). Ping for re-grade after push.

solomonneas and others added 2 commits August 1, 2026 16:44
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Codex <codex@openai.com>
@solomonneas

Copy link
Copy Markdown
Collaborator Author

Re-grade requested.

  1. d82cba07 moves the Unix symlinked-parent refusal ahead of temp creation and publish. TestWriteFile_RefusesSymlinkedParentBeforePublish proves a pre-created parent symlink leaves its target directory untouched. fsync_other.go now documents that non-Unix builds cannot make this parent-symlink refusal.
  2. c489cffd chmods the open temp descriptor before close. TestWriteFile_ChmodTargetSwappedMidWrite replaces the temp pathname with a victim symlink at chmod time and verifies the victim remains mode 0644.

Verification: go test ./... in stations/notify completed through Brigade. Receipt: 20260801-204537-work-verify-2d51ff.

@solomonneas

Copy link
Copy Markdown
Collaborator Author

Grader: APPROVE, merging. Both required changes verified: the symlinked-parent refusal now precedes temp creation and publish (d82cba0), with TestWriteFile_RefusesSymlinkedParentBeforePublish proving the target directory is untouched, and the chmod is now on the open descriptor (c489cff), with TestWriteFile_ChmodTargetSwappedMidWrite proving a swapped victim symlink stays 0644. The non-Unix gap is documented rather than silently absent. Receipt 20260801-204537-work-verify-2d51ff, CI green.

@solomonneas
solomonneas merged commit 0c781e1 into main Aug 1, 2026
28 checks passed
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.

security(notify): make init --force resistant to symlink and TOCTOU writes

1 participant