fix(sandbox): match the read deny-list against a rule's resolved path, greening shared macOS/Windows CI - #5724
fix(sandbox): match the read deny-list against a rule's resolved path, greening shared macOS/Windows CI#5724Hmbown wants to merge 2 commits into
Conversation
…, greening shared macOS/Windows CI Hosted `Test (macos-latest)` and `Test (windows-latest)` have been red on main since S1 landed, and every open PR riding main inherited the same failures (#5712 #5719 #5720 #5721 #5703 #5722 — verified from each exact head's own job logs). macOS (6 failures in sandbox::read_guard::tests): the hosted runner's $TMPDIR is /var/folders/... — a symlink into /private/var/... — so `canonicalize` and `current_dir` hand back the resolved spelling while the rule was only lexically normalized against the literal one. The canonicalized candidate could therefore never match a rule, and none of the symlink / denied-tree tests fired. Prior local verification passed only because it ran with TMPDIR on a plain volume. That is a product hole, not a test artifact: on macOS /etc, /var and /tmp are symlinks into /private, so `read_file /private/etc/sudoers` walked around the built-in /etc/sudoers rule (the Seatbelt setter already canonicalized its own copy of the list; the in-process matcher did not). A subtree rule now remembers its resolved spelling (`DenyRule::subtree`) and `check` matches a candidate against either spelling. Exemptions still compare the configured spelling only — unchanged, out of scope here. Windows (1 failure): root_parent_traversal_does_not_escape_above_root asserted a Unix `/etc` while `normalize_lexically("/../../etc")` correctly resolves a rooted-but-driveless path against the cwd's drive (`D:\etc` on the runner). The test now spells the traversal from the current drive root and keeps the Unix literal under `cfg!(unix)`. Defeat tests (fail on the unpatched matcher on every host, independent of TMPDIR): rule_spelled_through_a_symlinked_root_matches_the_resolved_spelling (unix) and macos_private_spelling_of_a_machine_wide_rule_is_refused (macOS). Local, hosted-equivalent (RUSTFLAGS=-Dwarnings, RUST_MIN_STACK=16MiB, cargo nextest run -p codewhale-tui --lib --all-features --locked --profile ci, TMPDIR = macOS default /var/folders/...): baseline at 20e9574: 22 run / 16 passed / 6 failed (the hosted six); after: 24 run / 24 passed. Defeat probe with rule resolution disabled (tests kept): plain TMPDIR 6 run / 2 failed = exactly the two new tests; symlinked TMPDIR 6 run / 4 failed. fmt clean; changelog synced. Hosted CI is the final gate. Not touched (branch-specific, not shared-main): #5712's Windows runtime_api::tests::events_endpoint_respects_since_seq_cursor timeout (8.91 s vs the 8 s ci_scaled budget; #5712 does not touch runtime_api) and #5722's Ubuntu fleet::executor failure. No-Issue: shared-main CI repair; no tracking issue exists for the hosted-runner symlink shape. Signed-off-by: CodeWhale Bot <bot@codewhale.net> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @Hmbown's task in 1m 57s —— View job Review complete
SummaryThis is a focused, well-scoped fix. The root cause description matches the code: Correctness
Tests
Nothing else flagged. No provider/model-routing code is touched, no I wasn't able to execute |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 414eafba49
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| continue; | ||
| } | ||
| subtrees.push(DenyRule::Subtree { path, label }); | ||
| subtrees.push(DenyRule::subtree(path, label)); |
There was a problem hiding this comment.
Apply exemptions to the resolved rule path
When a built-in rule is spelled through a symlink, exemptions are still compared only with its literal path before DenyRule::subtree adds the resolved spelling. For example, on macOS a denial for /private/etc/sudoers tells the user to add that requested path to sandbox_read_denylist_exempt, but the exemption does not match the literal /etc/sudoers rule, which is retained and resolves back to /private/etc/sudoers; the documented escape hatch therefore cannot reopen the path under the spelling that triggered the denial. Compare each exemption against both the literal and resolved rule paths.
Useful? React with 👍 / 👎.
|
|
||
| impl DenyRule { | ||
| /// A subtree rule that also remembers where its path really leads. | ||
| fn subtree(path: PathBuf, label: &'static str) -> Self { |
There was a problem hiding this comment.
Refresh resolved rules after symlink changes
When a configured deny path is a symlink that is replaced or retargeted during a long-running session, resolved remains a startup-time snapshot. A direct read beneath the new target is then allowed because check compares it only with the literal alias and the old target, while the old target remains denied even after the alias stops pointing there. This can occur when another process atomically rotates a symlinked credential directory, so the rule should be re-resolved during checks or refreshed when its filesystem topology changes.
Useful? React with 👍 / 👎.
| } | ||
| } | ||
| let _restore = Restore(prior); | ||
| std::env::set_current_dir(&denied).expect("chdir into the denied tree"); |
There was a problem hiding this comment.
Avoid changing the process cwd in a parallel unit test
Under the documented cargo test -p codewhale-tui --lib --locked invocation, this test can run concurrently with relative_read_from_inside_a_denied_tree_is_refused, which also changes the process-wide cwd; the local test runner's --help confirms that “By default, all tests are run in parallel.” If either test switches cwd between the other's set_current_dir and relative list.check(...), that check resolves against the wrong temporary directory and intermittently fails. Use a subprocess or otherwise serialize both cwd-mutating tests.
AGENTS.md reference: crates/tui/AGENTS.md:L34-L40
Useful? React with 👍 / 👎.
… cwd-moving tests Codex review follow-ups on #5724, each verified before fixing: - Exemptions compared only the literal rule path, so a denial for `/private/etc/sudoers` (the spelling the message names on macOS) pointed at an escape hatch that could not reopen the `/etc/sudoers` rule it resolved from. Exempt entries now carry both spellings and a rule is exempted when either of its spellings lies under either of theirs. New macOS test: exempting `/private/etc/sudoers` or `/etc/sudoers` reopens both spellings and leaves `/etc/ssh` armed (fails on the prior commit for the resolved spelling). - Two tests move the process cwd; under libtest's parallel threads they could interleave (nextest never contends). They now share a mutex. Verified with three consecutive `cargo test … -- read_guard::tests` runs (25/25 each) plus nextest 25/25. - Documented that rule spellings are resolved when the list is built (startup and config reload), so a symlink retargeted mid-session is seen through the literal candidate only until the list is rebuilt — within the module's stated defense-in-depth posture; not re-resolved per check. Signed-off-by: CodeWhale Bot <bot@codewhale.net> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @Hmbown's task in 1m 16s —— View job Review of the latest push (
|
|
Codex review findings, each verified before fixing — addressed in
Hosted evidence at |
|
Integrator gate: exact head c4bbb35 is mergeable and all required checks are green. The private Ops checker still denies only because root CHANGELOG.md is a Shipwright-owned collision point; the TUI implementation and crates/tui/CHANGELOG.md are Beacon-owned. Please remove only the root CHANGELOG.md delta, retain the crate-local changelog entry, and push a new head. I will rerun exact-head CI and the Beacon checker; there is no implementation or CI blocker beyond that ownership split. |
Summary
Restores a green shared base: hosted
Test (macos-latest)andTest (windows-latest)are red onmainat20e957406and every open PR riding it (#5712, #5719, #5720, #5721, #5703, #5722) inherits the same failures.macOS (6 failures,
sandbox::read_guard::tests) — hosted runners givetempfilea/var/folders/...directory that is really/private/var/folders/.... The deny-list compared the canonicalized candidate (and the already-canonical cwd) against a literal rule, so the six symlink / denied-tree tests never matched. Locally this only reproduces whenTMPDIRis the macOS default (the prior verification ran withTMPDIRon a plain volume).This is a product bug, not a test bug: on macOS
/etc,/var, and/tmpare symlinks into/private, soread_file /private/etc/sudoerswalked around the built-in/etc/sudoersrule (the OS-level Seatbelt setter already canonicalized its own copy; the in-process matcher did not). Fix: a subtree rule now remembers its resolved spelling (DenyRule::subtree) andcheckmatches a candidate against either spelling.Windows (1 failure) —
root_parent_traversal_does_not_escape_above_rootasserted a Unix/etcwhilenormalize_lexically("/../../etc")correctly resolves against the cwd's drive (D:\etcon the hosted runner). The test now spells the traversal from the current drive root and keeps the Unix literal undercfg!(unix).Defeat tests added (fail on the unpatched tree regardless of
TMPDIR):rule_spelled_through_a_symlinked_root_matches_the_resolved_spelling(unix): rule written through a symlinked directory; direct read, symlink read, and relative-from-cwd read are all refused; the real directory's innocent sibling stays readable.macos_private_spelling_of_a_machine_wide_rule_is_refused(macOS):/private/etc/sudoersand/private/etc/ssh/…are refused by the/etc/*defaults;/private/etc/hostsstays readable.Not in this PR (branch-specific, not shared-main): the Windows
runtime_api::tests::events_endpoint_respects_since_seq_cursortimeout appeared only on #5712's Windows run (8.91 s against an 8 sci_scaledbudget; #5712 does not touchruntime_api), and #5722's Ubuntufleet::executorfailure is its own.No-Issue: shared-main CI repair; no tracking issue exists for the hosted-runner symlink shape.
Testing
Focused, hosted-equivalent (
RUSTFLAGS=-Dwarnings,RUST_MIN_STACK=16MiB,cargo nextest run -p codewhale-tui --lib --all-features --locked --profile ci,TMPDIR= macOS default/var/folders/…):20e957406:22 tests run: 16 passed, 6 failed— the same six as hosted macOS.TMPDIR:24 tests run: 24 passed(22 prior + 2 new).TMPDIR→6 run: 4 passed, 2 failed— exactly the two new tests; macOS-defaultTMPDIR→6 run: 2 passed, 4 failed— the two new tests plus the two hosted-CI originals in the sample.Broader local gates on the fixed tree (same flags, nextest
--profile ci):read_guard under a plain (non-symlinked)
TMPDIR:24 run: 24 passedSafety-gate filter (
command_safety|auto_review|authority|sandbox):336 run: 336 passedDeny-list consumers (
tools::{file,search,file_search,read_media},config::):601 run: 601 passedFull TUI library suite:
11540 run: 11540 passed, 13 skippedcargo clippy -p codewhale-tui --all-targets --all-features --lockedwith the CI allow list: cleanWindows-target
cargo check(x86_64-pc-windows-msvc) could not run on this macOS host (ring's build script needs a Windows C toolchain), so hostedTest (windows-latest)remains the only Windows compile/run evidence.cargo fmt --all -- --checkcargo clippy -p codewhale-tui --all-targets --all-features --locked(warning-free under the CI allow list; workspace form not re-run — no other crate changed)cargo test --workspace --all-features --locked(hosted CI; locally the changed crate's full lib suite passed as above)Checklist
🤖 Generated with Claude Code