Repository navigation
hardening(tools): create scratch homes fresh - #93
Conversation
…nked targets (red) Drives the real FileWrite/FileEdit entry points against a private dgx-fw- temp tree: final and intermediate symlinks (outside file, outside dir, dangling, inside the workspace), hard links, and bounded swap races on the final and an intermediate component. 11 of 16 fail on an assertion against main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4
file_write and file_edit now open through tools/safe_write.rs: the workspace root is canonicalized once, then every component below it is opened with openat + O_NOFOLLOW relative to the previous directory descriptor (missing directories via mkdirat), so no component may be a symbolic link and nothing can be swapped between a check and the open. The target must be a regular file with one hard link; on Linux the opened descriptor's /proc path is re-checked against the workspace before any byte is written (fails closed without /proc). Non-Unix platforms refuse every write. file_edit reads and rewrites through the same descriptor. Removes the old canonicalize-the-ancestor check and the post create_dir_all re-check, which the descriptor walk replaces. rustix (already in Cargo.lock) gains a direct unix-only dependency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4
scripts/check_tool_write_paths.py flags fs::write / File::create / OpenOptions / create_dir_all / fs::copy / fs::rename / symlink / hard_link in agent-code/src/tools outside safe_write.rs unless the line (or the one above) carries '// write-path-ok: <reason>'. --self-test holds the negative controls; against main it flags the old file_write and file_edit write sites. The two tool-owned scratch-HOME dirs in git_operations and shell_exec are marked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4
… fresh (red) Pins that the scratch HOME the two subprocess tools use is never a directory or symlink already present at the name the tool would pick, is owner-only (0700), and is removed after the call. tests/scratch_home_fresh.rs drives git_ops and shell_exec with TMPDIR pointed at a private tree and plants a directory / a symlink at every predictable name; git's default exclude file under $HOME shows whether the planted HOME was used (a negative-control test proves the method). tools/scratch_home.rs is a stub of the current behaviour so the unit tests compile; 4 tool-level and 3 unit tests fail on assertions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4
git_ops and shell_exec now get their scratch HOME from tools/scratch_home.rs: tempfile::Builder with a 16-character random suffix and mode 0700, created by a single mkdir that fails if anything already sits at the name (so an existing directory or symlink is never reused or followed; tempfile retries with a new name). The directory is a TempDir guard, so it is removed on every return path, including a git_ops timeout, which previously skipped the cleanup. Failure to create it is an error instead of being ignored. The per-process counters that built the old names are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4
scripts/check_tool_write_paths.py now also flags temp_dir() / process::id() in agent-code/src/tools outside scratch_home.rs (marker `// scratch-path-ok: <reason>`), and skips #[cfg(test)] items so test fixtures in a tool file are not reported. --self-test adds negative controls (the old hand-built name is flagged; code after a test module is still scanned); against the previous tool sources it flags the two old scratch-HOME sites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4
Matr0xshka
left a comment
There was a problem hiding this comment.
[MAC independent review] APPROVED at 91370ef
Reviewer: MAC-lane independent reviewer agent (did not author this PR). Not Codex, not DGX. Per owner decision 2026-10-23 this review stands in for the Codex and DGX gates.
Head verified live: 91370ef660a495ff6c959c0feca596ece9591cc5. Base: hardening/file-write-nofollow (#91, approved at d49f9f7). I judged the diff against that base. Author: SaulBuilds. There are no prior reviews and no Codex result at this head. This closes item 2 of #95.
What I ran (fresh worktree, own target dir, macOS)
cargo test -p citrate-agent-code --no-fail-fast --locked: lib 60, file_write_symlink 16, scratch_home_fresh 6, all green (82, matching the PR body).python3 scripts/check_tool_write_paths.py .is clean, and--self-testis ok.- rustfmt on the new files is clean. Clippy on agent-code shows only
safe_write.rs:103(unusedAsFd) andshell_exec.rs:185, and both are on the base.
Revert check (reproduced): I copied git_operations.rs, shell_exec.rs and scratch_home.rs aside and restored them from the red commit 290dda2, keeping the tests. Results:
- lib: 57 pass / 3 fail (pre-created dir, symlink, mode).
- scratch_home_fresh: 2 pass / 4 fail (all four tool-level planted-HOME assertions).
- tripwire: exit 1 at
git_operations.rs:32,34andshell_exec.rs:287,289.
With the files restored, everything is green.
Probes (throwaway test through ShellExec::execute, private TMPDIR, not committed)
- 32 concurrent
executecalls all succeed, and 0 entries are left in TMPDIR. - Timeout (
tail -f /dev/null,timeout_ms: 300): returnsTimeout(300)and the scratch HOME is removed. However, the child process is still running afterwards (nokill_on_drop). This is not a regression: the old code didn't kill the child either. It is filed as a follow-up. - Unwritable temp dir: returns
ExecutionFailed("could not create a scratch HOME directory: Permission denied ..."). It fails closed and does not run without a HOME. - Mode 0700 is correct no matter the umask, because umask only clears bits. Cleanup goes through
TempDir/stdremove_dir_all, which does not follow links inside the directory.
Findings: no P1. P2s:
shell_exec.rs:296/git_operations.rs:33: a timed-out child outlives its HOME.scripts/check_tool_write_paths.pynon_test_lines: brace counting includes braces inside string literals, so an unbalanced"{"in a test item could extend the skip.
CI: rust, the tripwire job, Windows, HIC and secret-scan are green. audit (with the ci aggregate that reports it) is red because of new RustSec advisories in existing dependencies (crypto-bigint RUSTSEC-2026-0387 and others). This PR adds no crate: the Cargo.lock delta against main is the rustix line from #91. Another PR's cargo audit failed in the same window, so this is known and unrelated.
Standard: met. Red-first 290dda2, revert check reproduced, tripwire extended with negative controls, and the dead counters are removed.
Follow-up filed: #96.
Verification cleared: yes.
BerryManifold
left a comment
There was a problem hiding this comment.
[MAC independent review] APPROVED at 91370ef
Reviewer: MAC-lane independent reviewer agent (did not author this PR). Not Codex, not DGX. Per owner decision 2026-10-23 this review stands in for the Codex and DGX gates.
Head verified live: 91370ef660a495ff6c959c0feca596ece9591cc5. Base: hardening/file-write-nofollow (#91, approved at d49f9f7). I judged the diff against that base. Author: SaulBuilds. There are no prior reviews and no Codex result at this head. This closes item 2 of #95.
What I ran (fresh worktree, own target dir, macOS)
cargo test -p citrate-agent-code --no-fail-fast --locked: lib 60, file_write_symlink 16, scratch_home_fresh 6, all green (82, matching the PR body).python3 scripts/check_tool_write_paths.py .is clean, and--self-testis ok.- rustfmt on the new files is clean. Clippy on agent-code shows only
safe_write.rs:103(unusedAsFd) andshell_exec.rs:185, and both are on the base.
Revert check (reproduced): I copied git_operations.rs, shell_exec.rs and scratch_home.rs aside and restored them from the red commit 290dda2, keeping the tests. Results:
- lib: 57 pass / 3 fail (pre-created dir, symlink, mode).
- scratch_home_fresh: 2 pass / 4 fail (all four tool-level planted-HOME assertions).
- tripwire: exit 1 at
git_operations.rs:32,34andshell_exec.rs:287,289.
With the files restored, everything is green.
Probes (throwaway test through ShellExec::execute, private TMPDIR, not committed)
- 32 concurrent
executecalls all succeed, and 0 entries are left in TMPDIR. - Timeout (
tail -f /dev/null,timeout_ms: 300): returnsTimeout(300)and the scratch HOME is removed. However, the child process is still running afterwards (nokill_on_drop). This is not a regression: the old code didn't kill the child either. It is filed as a follow-up. - Unwritable temp dir: returns
ExecutionFailed("could not create a scratch HOME directory: Permission denied ..."). It fails closed and does not run without a HOME. - Mode 0700 is correct no matter the umask, because umask only clears bits. Cleanup goes through
TempDir/stdremove_dir_all, which does not follow links inside the directory.
Findings: no P1. P2s:
shell_exec.rs:296/git_operations.rs:33: a timed-out child outlives its HOME.scripts/check_tool_write_paths.pynon_test_lines: brace counting includes braces inside string literals, so an unbalanced"{"in a test item could extend the skip.
CI: rust, the tripwire job, Windows, HIC and secret-scan are green. audit (with the ci aggregate that reports it) is red because of new RustSec advisories in existing dependencies (crypto-bigint RUSTSEC-2026-0387 and others). This PR adds no crate: the Cargo.lock delta against main is the rustix line from #91. Another PR's cargo audit failed in the same window, so this is known and unrelated.
Standard: met. Red-first 290dda2, revert check reproduced, tripwire extended with negative controls, and the dead counters are removed.
Follow-up filed: #96.
Verification cleared: yes.
BerryManifold
left a comment
There was a problem hiding this comment.
Automated Codex static review of 91370ef660a495ff6c959c0feca596ece9591cc5 is complete. Approval is withheld pending private security review. Details and coverage limits are recorded in the project's private review log. No tests were executed by this reviewer. Both account submissions represent the same Codex review, not independent reviews.
Matr0xshka
left a comment
There was a problem hiding this comment.
Automated Codex static review of 91370ef660a495ff6c959c0feca596ece9591cc5 is complete. Approval is withheld pending private security review. Details and coverage limits are recorded in the project's private review log. No tests were executed by this reviewer. Both account submissions represent the same Codex review, not independent reviews.
The base branch was changed.
…resh #91 was squash-merged to main; this branch's old base is its head d49f9f7. ci.yml, tools/git_operations.rs, tools/mod.rs, tools/shell_exec.rs and scripts/check_tool_write_paths.py take this branch (main == old base). agent-code/Cargo.toml auto-merged against the older merge base and duplicated the [target.'cfg(unix)'.dependencies] rustix block; it takes this branch's version (the three-way result against d49f9f7). The PR diff against main equals its old diff against d49f9f7. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
[MAC] Updated from main at bb611c1 (was 91370ef) Merge commit Old base: Conflicts: One silent bad merge fixed: Diff checks:
Test names: nothing lost. Totals: PR 90, main 145, merge 156. Results at bb611c1:
The pre-push hook was skipped ( 🤖 Generated with Claude Code |
Matr0xshka
left a comment
There was a problem hiding this comment.
[MAC independent review] APPROVED at bb611c1
Reviewer: MAC-lane independent reviewer agent (did not author this PR). Not Codex, not DGX. Per owner decision 2026-10-23 this review stands in for the Codex and DGX gates.
Re-review of a merge-up. I approved this PR at 91370ef.
Live head: bb611c1, checked via REST. Since 91370ef there is one first-parent commit: merge bb611c1 = 91370ef + main 48f6f17 (#91 squash). Old base: d49f9f7, #91's head.
The hand-fixed agent-code/Cargo.toml
- At the head it has exactly one
[target.'cfg(unix)'.dependencies]block. Git's merge base had produced a duplicated rustix block, and that is gone. - The file equals this branch's version:
git diff 91370ef bb611c1 -- agent-code/Cargo.tomlis empty. Cargo.lockequals main.
Merge
- I checked all 27 files either side touched against the exact three-way merge on
d49f9f7(git merge-file, or the untouched side's blob). There are 0 mismatches. - The PR's +/- lines are identical to the approved diff.
- No test names were lost (PR 90, main 145, merge 156).
What I ran at bb611c1 (own worktree and target dir)
cargo test -p citrate-agent-code --no-fail-fast(RUST_TEST_THREADS=2): 88 passed, 0 failed (lib 60, file_write_symlink 16, scratch_home_fresh 6, tool_entry_pins 6).scripts/check_tool_write_paths.py: clean.--self-test: ok.
CI at bb611c1: green except audit and the ci aggregate. audit fails at cargo audit on existing-dependency advisories, among them RUSTSEC-2026-0387. Main fails the same way, and this PR adds no crate (its Cargo.lock equals main's). The tool-layer write-path tripwire job, rust, Windows, HIC and secret-scan are all green.
Revert check: not repeated. It carries over from 91370ef, and the PR diff is unchanged.
Standard: met.
Verification cleared: yes.
DCO: commits signed off.
BerryManifold
left a comment
There was a problem hiding this comment.
[MAC independent review] APPROVED at bb611c1
Reviewer: MAC-lane independent reviewer agent (did not author this PR). Not Codex, not DGX. Per owner decision 2026-10-23 this review stands in for the Codex and DGX gates.
Re-review of a merge-up. I approved this PR at 91370ef.
Live head: bb611c1, checked via REST. Since 91370ef there is one first-parent commit: merge bb611c1 = 91370ef + main 48f6f17 (#91 squash). Old base: d49f9f7, #91's head.
The hand-fixed agent-code/Cargo.toml
- At the head it has exactly one
[target.'cfg(unix)'.dependencies]block. Git's merge base had produced a duplicated rustix block, and that is gone. - The file equals this branch's version:
git diff 91370ef bb611c1 -- agent-code/Cargo.tomlis empty. Cargo.lockequals main.
Merge
- I checked all 27 files either side touched against the exact three-way merge on
d49f9f7(git merge-file, or the untouched side's blob). There are 0 mismatches. - The PR's +/- lines are identical to the approved diff.
- No test names were lost (PR 90, main 145, merge 156).
What I ran at bb611c1 (own worktree and target dir)
cargo test -p citrate-agent-code --no-fail-fast(RUST_TEST_THREADS=2): 88 passed, 0 failed (lib 60, file_write_symlink 16, scratch_home_fresh 6, tool_entry_pins 6).scripts/check_tool_write_paths.py: clean.--self-test: ok.
CI at bb611c1: green except audit and the ci aggregate. audit fails at cargo audit on existing-dependency advisories, among them RUSTSEC-2026-0387. Main fails the same way, and this PR adds no crate (its Cargo.lock equals main's). The tool-layer write-path tripwire job, rust, Windows, HIC and secret-scan are all green.
Revert check: not repeated. It carries over from 91370ef, and the PR diff is unchanged.
Standard: met.
Verification cleared: yes.
DCO: commits signed off.
Matr0xshka
left a comment
There was a problem hiding this comment.
Approval at c08562b, recorded on the owner's instruction (merge train).
c08562b is the head approved by the [MAC independent review] APPROVED review (bb611c1), with only the default branch merged in since.
What this approval measured:
- Independent MAC-reviewer approval at the reviewed head (this stands in for the Codex and DGX gates, per the owner's 2026-10-23 decision).
- Note: scratch homes fresh (re-acked).
- CI at
c08562b:ci=success, secret-scan=success.
This does not record a fresh, independent re-run of the tests.
BerryManifold
left a comment
There was a problem hiding this comment.
Approval at c08562b, recorded on the owner's instruction (merge train).
c08562b is the head approved by the [MAC independent review] APPROVED review (bb611c1), with only the default branch merged in since.
What this approval measured:
- Independent MAC-reviewer approval at the reviewed head (this stands in for the Codex and DGX gates, per the owner's 2026-10-23 decision).
- Note: scratch homes fresh (re-acked).
- CI at
c08562b:ci=success, secret-scan=success.
This does not record a fresh, independent re-run of the tests.
Stacked on #91 (
hardening/file-write-nofollow): it touches the same lines ingit_operations.rs/shell_exec.rsand extends its tripwire. Base is the #91 branch; retarget tomainonce #91 merges.What changes
git_opsandshell_execpoint the subprocess HOME at a scratch directory under the temp dir. The name was the process id plus a counter, created withcreate_dir_all, so a directory or symlink already at that name was used as-is.agent-code/src/tools/scratch_home.rs:ScratchHomewrapstempfile::Builderwith a 16-character random suffix and mode 0700. It is created by a singlemkdir, which fails if anything already sits at the name (directory, file, symlink, dangling link);tempfileretries with a new name. Nothing pre-existing is reused and no link is followed.TempDirguard, so the directory is removed on every return path. That includes agit_opstimeout, where the old code skipped the cleanup.agent-browser::new_profile_dirandagent-shell::ScratchDiralready use a create-exclusive 0700mkdirand skip names that exist.agent-checkpoints/agent-sidecartemp names live inside their own directories, not the shared temp dir. Nothing else inagent-code/src/toolsbuilds temp-dir names.Tests
agent-code/tests/scratch_home_fresh.rs(6 tests) drives the realGitOps/ShellExecentry points withTMPDIRpointed at a privatedgx-shf-tree. The tests are serialized by one lock whileTMPDIRis overridden. The setup plants a directory, or a symlink, at every predictable name for counters 0..255. With the environment cleared, git reads$HOME/.config/git/ignore. The planted HOME hidesmarker.txt, sogit status --porcelainshows whether the planted HOME was used. A negative-control test proves this check detects a used HOME. After each call, the tests assert that the planted dirs, links and link target are intact and that nothing is left in the temp dir.scratch_home.rsunit tests (5): a pre-created dir with a planted file is not used, a planted symlink is not followed, the mode is 0700, the directory is removed on drop (with children), and 128 concurrent creates get distinct dirs.Tripwire
scripts/check_tool_write_paths.py(from #91) now also flagstemp_dir()/process::id()inagent-code/src/toolsoutsidescratch_home.rs. The exception marker is// scratch-path-ok: <reason>. The script also skips#[cfg(test)]items, so test fixtures in a tool file are not reported.--self-testhas new negative controls: the old hand-built name is flagged, and code after a test module is still scanned. Run against the previous tool sources, it flagsgit_operations.rs:32,34andshell_exec.rs:287,289. The CI job name is updated.Standard: met
290dda2adds the tests plus a stub ofScratchHomethat has the old behaviour, so they compile. 7 tests fail on assertions: the 4 tool-level marker assertions, plus unit tests for the pre-created dir, the symlink and the mode (0755 under umask 022).c9ed17b. Tripwire:91370ef.290dda2with the tests kept. Lib: 57 pass, 3 fail.scratch_home_fresh: 2 pass, 4 fail. Tripwire: exit 1 at the 4 old sites. After restoring, all are green.cargo +1.96.0 test -p citrate-agent-code --no-fail-fast, RUST_TEST_THREADS=2): 71 before (55 lib + 16 file_write_symlink), 82 after (60 lib + 16 + 6 scratch_home_fresh).cargo clippy -p citrate-agent-code --all-targets --lockedadds no new warnings in agent-code; the one remaining warning,shell_exec.rs:185, is already on the base. The new files are rustfmt-clean. I did not reformat existing files, and the workspacecargo fmt --check/-D warningsfailures in other crates are already on the base.tempfileprefix, andTMPDIRis restored on drop. There is no glob delete and no network.Left open
tempfileuses its default permissions there. The tool layer's write path is already Unix-only (hardening(file_write): refuse a symlink target #91).🤖 Generated with Claude Code
https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4