Skip to content

fix(sandbox): canonicalize deny-rule paths at build; unbreak macOS/Windows read_guard baseline - #5729

Closed
aboimpinto wants to merge 1 commit into
Hmbown:mainfrom
aboimpinto:fix/sandbox-read-guard-main-baseline
Closed

fix(sandbox): canonicalize deny-rule paths at build; unbreak macOS/Windows read_guard baseline#5729
aboimpinto wants to merge 1 commit into
Hmbown:mainfrom
aboimpinto:fix/sandbox-read-guard-main-baseline

Conversation

@aboimpinto

@aboimpinto aboimpinto commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the sandbox::read_guard baseline failures that are currently red on main's own CI (macOS + Windows) and block every PR targeting main — including FEAT-021 #5717, whose only red checks are these exact tests.

Two independent defects in crates/tui/src/sandbox/read_guard.rs:

1. macOS — deny rules were lexical while candidates were canonical (real deny-bypass gap).

  • ReadDenylist::build stored rule and exempt paths with normalize_lexically only.
  • check() compares canonicalize_best_effortd candidates against those rules via path_is_within (component-wise).
  • On macOS the system redirects /var (and /tmp) to /private/.... A temp-dir deny rule stayed /var/folders/... while the canonical candidate was /private/var/folders/... → first component mismatch → the deny was silently missed. This is why the symlink/.. evasion tests (dot_dot_through_a_symlink…, symlink_chains…, relative_read_from_inside_a_denied_tree…, symlinked_parent_directory…, symlink_pointing_into_a_denied_tree…, denial_message_names_the_rule…) fail on macOS but pass on Linux.
  • Consequence in production: any user-listed deny under a symlinked root on macOS could be bypassed.

Fix: canonicalize every rule and exempt path at build time (canonicalize_best_effort(&normalize_lexically(&path))), so both sides of path_is_within compare canonical paths. subtree_paths() now hands the OS wrappers the canonical form, which is also the correct form for Seatbelt on macOS. Non-existent built-ins (~/.ssh, …) resolve to their canonical parent + suffix as before. canonicalize_best_effort never fails to produce at least the root-canonicalized path, so no rule can be lost.

2. Windows — root_parent_traversal_does_not_escape_above_root asserted Unix path semantics everywhere.

  • The test asserted normalize_lexically(Path::new("/../../etc")) == PathBuf::from("/etc"). On Windows a leading / is not absolute (drive-relative), so the path joins current_dir() and normalizes to the drive root — the assertion failed.
  • On macOS it also failed because subtree_paths_feed_the_os_wrappers_and_omit_the_filename_rule compared against the lexical form (now canonical).

Fix: per-platform assertions — Unix keeps /../../etc/etc; Windows uses a drive-rooted path C:\..\..\etcC:\etc (the root never pops above itself). The subtree_paths test now asserts the canonical form.

Testing

  • cargo fmt --all -- --check
  • cargo test -p codewhale-tui --lib --locked sandbox::read_guard — 20 passed / 0 failed (Linux)
  • cargo test -p codewhale-tui --lib --locked sandbox:: — 74 passed / 0 failed (Linux)
  • cargo check --workspace --locked — clean
  • macOS/Windows behavior verified by reasoning (canonicalize resolves the /var → /private/var redirect; the per-platform test expectations follow the respective path models); the previous CI matrix on main reproduced the failures, so a green macOS/Windows run on this PR is the acceptance signal.

Note: the only remaining clippy error in this workspace (too_many_arguments in runtime_threads.rs:2562) is pre-existing on main with this toolchain and unrelated to this change.

Paulo Aboim Pinto


Devin Review

… root-parent test

macOS read_guard baseline (CI red on main):
- Rules and exempts were stored lexically (normalize_lexically), while
  check() compares canonicalized candidates. On macOS the system redirects
  /var and /tmp to /private/..., so a denied path under a symlinked root
  never matched the canonical candidate — the deny could be walked around.
  Canonicalize every rule and exempt path at build time so both sides of
  path_is_within compare canonical paths (subtree_paths() now hands the OS
  wrappers the canonical form, which is also correct for Seatbelt).
- root_parent_traversal_does_not_escape_above_root asserted Unix path
  semantics on every platform: a leading '/' is not absolute on Windows, so
  the test failed there. Split the assertion per platform (drive-rooted
  Windows expectation).
@aboimpinto
aboimpinto requested a review from Hmbown as a code owner August 30, 2026 08:14

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

if path.as_os_str().is_empty() {
continue;
}
let path = canonicalize_best_effort(&path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Nonexistent rules retain their suffix

canonicalize_best_effort resolves the deepest existing ancestor and reattaches the missing suffix. Configured future paths remain denied without broadening the rule.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1036 to +1039
// Rules are canonicalized at build (symlinked roots such as macOS
// `/var` → `/private/var` must not split the comparison), so the OS
// wrapper handoff is the canonical form of the temp dir.
assert_eq!(paths[0], canonicalize_best_effort(tmp.path()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Canonical wrapper handoff is consistent

The wrapper setter now receives an already canonical path. Seatbelt matches that kernel-resolved spelling, while bubblewrap masks the same resolved filesystem object.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@aboimpinto

Copy link
Copy Markdown
Contributor Author

Closing — superseded by the maintainer's own fix, merged in #5724 ("match the read deny-list against a rule's resolved path"). This PR was prepared in parallel and takes the same approach (canonicalize/resolve deny rules at build + per-platform root-parent assertion); the merged fix covers the same ground. Closing to avoid overlap.

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.

1 participant