refactor(cli): atomic hook-salt publish via temp file + hard_link#30
Conversation
Replace the first-run machine-salt persistence — `O_CREAT|O_EXCL` then a separate `write`, bridged by a bounded read-retry loop — with a fully-written temp file linked into place via `hard_link`. `hard_link` is a single atomic syscall that fails `EEXIST` when the target exists, so it keeps the single-winner property while making the salt file appear only once it is fully written. This closes the create→write crash micro-window (a winner that died between the exclusive create and its write left a 0-byte file, forcing a loser onto its own unpersisted salt for one invocation) and removes the read-retry loop entirely: a loser now reads the winner's complete bytes in a single shot. - Add `publish_secret_atomically()` + a per-process `TEMP_SEQ` counter (temp names are unique per process via PID and per thread via the counter). - Remove `read_valid_salt_retrying()` and `create_secret_file_exclusive()`. - Tests: `first_run_loser_adopts_complete_winner_salt` (deterministic loser path, no empty-file window) and `machine_salt_concurrent_first_run_converges_to_one_key` (16-thread convergence + temp cleanup). Closes #28
There was a problem hiding this comment.
Code Review
This pull request replaces the exclusive file creation and retry-read loop for generating machine salt with an atomic publish mechanism using a temporary file and hard links, alongside adding concurrent race tests. Feedback suggests avoiding the temporary file and hard link pattern because it can fail on certain filesystems (like NFS or Docker) and may lead to convergence issues, recommending instead to stick to exclusive file creation directly on the target file with a retry-read mechanism for losers.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Merging this PR will degrade performance by 24.81%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | parse_k8s_path[/api/v1/namespaces/prod/secrets/db-password] |
5.8 µs | 9.1 µs | -36.4% |
| ❌ | parse_k8s_path[/apis/apps/v1/namespaces/staging/deployments/api] |
6.2 µs | 7 µs | -11.1% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing amondnet/hook-salt-atomic-publish (d2e2ce2) with main (84e340c)
…malies Address self-review findings on the hard_link salt publish: - Restore symlink-safe exclusive create for the first-run temp. The atomic publish had routed the temp write through a non-exclusive O_CREAT|truncate at a predictable path (hook-salt.tmp.<pid>.<seq>), which could follow or clobber a pre-planted symlink/file when `.honmoon` is attacker-writable (the relative fallback landing in a world-writable CWD). Create the temp with O_CREAT|O_EXCL at an unguessable random name instead; this also makes the temp unique across processes and threads without the TEMP_SEQ counter — a net simplification. - Log the two "vanishingly unlikely" lost-race fallback anomalies (short/corrupt vs. unreadable winner file) instead of swallowing both into one silent arm, matching the sibling logging in load_or_create_machine_salt. - Log a temp-cleanup failure (a lingering 0600 secret file) rather than discarding the error. - Add first_run_loser_falls_back_when_winner_file_is_short to cover the previously untested fallback arm. 23/23 honmoon-cli hook tests pass; clippy -D warnings and fmt clean.
Add the security reviewer's project memory on honmoon-cli's machine-salt threat model (0600 invariant, HMAC unforgeability key, fail-open fallback, and the filesystem-safety focus for future reviews of this file). Reflects the final state of this branch: the first-run temp is created O_CREAT|O_EXCL at a random name (symlink-safe) and hard_link-published; the remaining symlink-follow consideration on the read path is noted as pre-existing and non-default.
There was a problem hiding this comment.
All reported issues were addressed across 3 files
Architecture diagram
sequenceDiagram
participant Hook as Hook Process
participant FS as Filesystem
participant Dir as .honmoon dir
participant Salt as hook-salt file
participant Temp as hook-salt.tmp.<nonce>
Note over Hook,Temp: First-run machine salt publish flow
Hook->>Hook: Generate 32-byte HMAC salt<br/>(session_salt payload)
alt No hook-salt exists (first run)
Hook->>Hook: publish_secret_atomically(dir, path, salt)
Note over Hook,Temp: Step 1 — Write to exclusive temp<br/>(O_CREAT|O_EXCL, mode 0600)
Hook->>Dir: Create temp with random 16-byte nonce
activate Temp
Hook->>FS: create_secret_file_exclusive(tmp, salt)
FS-->>Temp: Write salt bytes, close file
deactivate Temp
Note over Hook,Temp: Step 2 — Atomic publish via hard_link
Hook->>FS: hard_link(tmp, path)
alt Link succeeds (we are the winner)
FS-->>Salt: Salt now at path (atomic, already complete)
FS-->>Hook: Ok(())
Hook->>FS: remove_file(tmp) — cleanup
Hook-->>Hook: Return our own salt
else Link fails with AlreadyExists (loser race)
FS-->>Hook: EEXIST
Hook->>FS: remove_file(tmp) — cleanup our temp
Note over Hook,Salt: Single read — no retry loop needed<br/>(winner's salt is already fully written)
Hook->>FS: read(path)
alt Read succeeds, >= 16 bytes
FS-->>Hook: Winner's complete salt
Hook-->>Hook: Adopt winner's salt
else Read succeeds but short/corrupt
FS-->>Hook: Short bytes
Note over Hook: LOG: anomaly — winner salt short/corrupt
Hook-->>Hook: Fall back to our own salt
else Read fails with error
FS-->>Hook: IO error
Note over Hook: LOG: unexpected error reading winner salt
Hook-->>Hook: Fall back to our own salt
end
else Link fails with other error
FS-->>Hook: Permissions/IO error
Hook-->>Hook: Propagate error upward
end
else hook-salt exists (subsequent runs)
Hook->>FS: read(path)
alt Read succeeds, >= 16 bytes
FS-->>Hook: Persisted salt
Hook-->>Hook: Use persisted salt
else File short/corrupt or missing
Note over Hook: LOG: regenerating<br/>(self-heal path)
Hook->>Hook: Generate new salt
Hook->>Hook: publish_secret_atomically() — same atomic flow
end
end
Note over Hook: Salt used as HMAC-SHA256 key<br/>for placeholder unforgeability<br/>(session_salt)
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Greptile SummaryThis PR replaces the old first-run machine-salt publish (exclusive create → write → bounded retry loop) with a temp-file +
Confidence Score: 5/5The change is safe to merge — it tightens the first-run concurrency story without altering any existing interface or data format. The core logic is mechanically sound: hard_link atomically fails EEXIST when the target exists, the temp is written exclusively before linking so no reader ever sees an empty target, and the loser/fallback paths are exercised by three new tests. Both issues flagged in the previous review round are correctly fixed. The residual TOCTOU in attacker-writable directories is documented and acknowledged as out of the standard threat model. No files require special attention. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant P1 as Process A (winner)
participant P2 as Process B (loser)
participant FS as Filesystem
P1->>FS: random_bytes(16) → nonce_A
P2->>FS: random_bytes(16) → nonce_B
P1->>FS: create_new(hook-salt.tmp.nonce_A, 0600)
P2->>FS: create_new(hook-salt.tmp.nonce_B, 0600)
P1->>FS: write_all(salt_A)
P2->>FS: write_all(salt_B)
P1->>FS: hard_link(tmp_A → hook-salt)
FS-->>P1: Ok(()) — winner
P2->>FS: hard_link(tmp_B → hook-salt)
FS-->>P2: Err(AlreadyExists) — loser
P1->>FS: remove_file(tmp_A)
P2->>FS: remove_file(tmp_B)
P2->>FS: read(hook-salt)
FS-->>P2: salt_A (32 bytes, complete)
Note over P1,P2: Both return salt_A — converged
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant P1 as Process A (winner)
participant P2 as Process B (loser)
participant FS as Filesystem
P1->>FS: random_bytes(16) → nonce_A
P2->>FS: random_bytes(16) → nonce_B
P1->>FS: create_new(hook-salt.tmp.nonce_A, 0600)
P2->>FS: create_new(hook-salt.tmp.nonce_B, 0600)
P1->>FS: write_all(salt_A)
P2->>FS: write_all(salt_B)
P1->>FS: hard_link(tmp_A → hook-salt)
FS-->>P1: Ok(()) — winner
P2->>FS: hard_link(tmp_B → hook-salt)
FS-->>P2: Err(AlreadyExists) — loser
P1->>FS: remove_file(tmp_A)
P2->>FS: remove_file(tmp_B)
P2->>FS: read(hook-salt)
FS-->>P2: salt_A (32 bytes, complete)
Note over P1,P2: Both return salt_A — converged
Reviews (3): Last reviewed commit: "chore(agent-memory): scope salt TOCTOU t..." | Re-trigger Greptile |
…fety notes Address cubic + greptile review on the hard_link salt publish: - publish_secret_atomically: clean up the exclusively-created temp if the write fails. The `?` early-return previously leaked a partial 0600 temp into `dir` on repeated I/O errors (greptile P1 / cubic). - Only log the "may linger" temp-cleanup warning for non-NotFound errors — a NotFound means the temp is already gone, so the warning was misleading (greptile P2). - Stop overclaiming "symlink-safe": the exclusive random temp blocks pre-planting, but hard_link still leaves a TOCTOU-replace window in an attacker-writable, observable dir (only fd-based linking / a trusted dir closes it). Corrected the code doc + agent-memory security note and broadened the residual-risk scope beyond the HOME-unset fallback (cubic). Default $HOME/.honmoon is user-owned, so the residual gap is outside the standard threat model. 23/23 hook tests pass; clippy -D warnings and fmt clean.
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…d-writable) dirs cubic follow-up on #30: the temp-replace / symlink-substitution attacks need only an attacker-writable salt directory (group-writable/shared counts), not specifically world-writable. Correct the note's wording accordingly.
|



Summary
Replaces first-run machine-salt persistence (
O_CREAT|O_EXCL+ separate write, bridged by a bounded read-retry loop) with a fully-written temp file linked into place viahard_link.hard_linkis a single atomic syscall that failsEEXISTif the target exists — this keeps the single-winner property AND makes the salt file appear only once fully written.Changes
read_valid_salt_retrying()andcreate_secret_file_exclusive(); addedpublish_secret_atomically()+ a per-processTEMP_SEQatomic (temp names unique per process via PID, per thread via the counter).Test Plan
first_run_loser_adopts_complete_winner_salt(deterministic loser path, no empty-file window) andmachine_salt_concurrent_first_run_converges_to_one_key(16-thread convergence + temp cleanup)cargo clippy --all-targets -- -D warningscleancargo fmt --checkcleanRelated Issues
Closes #28
Summary by cubic
Make first-run machine-salt publish in
honmoon-cliatomic by writing to a random-name, 0600 temp created withO_CREAT|O_EXCLand linking it into place withhard_link. This removes the empty-file race, blocks temp pre-planting, and makes concurrent hooks converge on one salt (closes #28).Bug Fixes
Refactors
publish_secret_atomically()andhex_encode(); reintroducescreate_secret_file_exclusive(); removesread_valid_salt_retrying().Written for commit d2e2ce2. Summary will update on new commits.