Repository navigation
Linux sandbox 0.162: deny_read entries that name the same file through a symlink fail with "Unable to mount source on destination: No such file or directory" #52658
Description
Activity
- addedbugSomething isn't workingSomething isn't workingCLIIssues related to the Codex CLIIssues related to the Codex CLIsandboxIssues related to permissions or sandboxingIssues related to permissions or sandboxing
on Oct 9, 2026 Root cause confirmed on
main(45311351c2) — and your#48155pointer is right: that is the change that introduced it (645b683a9e, "Preserve filesystem denials when preparing approved commands").Why the alias pair exists
get_unreadable_roots_with_cwdnormalizes entries, so two spellings of one file collapse to the resolved path and/var/home/user/.codex/auth.jsonsurvives alone.get_unreadable_roots_with_cwd_preserving_symlinksthen pushes the literal entry back whenever!roots.contains(&entry.path). The doc comment says why: the literal is what lets Linux reject a denial that crosses a writable symlink, instead of masking only the symlink's current target (bwrap.rs,first_writable_symlink_component_in_path). So the literal is load-bearing for the fail-closed check and redundant for masking.Why the second mask fails
create_filesystem_argsdedups the root list only by path string (sort()+dedup()), so the alias pair survives it. Both entries are files, so both reachappend_existing_unreadable_path_args→--perms 000 --ro-bind-data <fd> <path>, once per spelling, from the rootless loop — exactly the two--ro-bind-dataargument you dumped. Directories are unaffected because--tmpfsmasks stack, which matches what you observed.Fix — your suggested rule, plus one refinement
Branch
fix/symlink-alias-file-mask-dedup, commitde942b793a(+101/−3,codex-rs/linux-sandbox/src/bwrap.rsonly):- Each mount phase claims the resolved identity of a file before masking it (
claim_file_mask_target), and an alias of an already-claimed file reuses that mask. - Identity comes from the longest existing ancestor, not the leaf, so a missing denied file resolves through the same identity too — and hard links keep separate identities, because masking one path does not hide the other.
- The claim set is per mount phase rather than global. This is deliberate: binding a writable root shadows masks applied before it, so the nested loop legitimately re-masks the same path after each bind, and a global set would drop those.
- The writable-symlink check still runs for the literal alias before it can be dropped, so the fail-closed behaviour that
#48155added is untouched: a denial that crosses a writable symlink still errors out.
The first mask keeps hiding the file through the alias, because the symlink itself lives under the read-only root.
Verification limits (worth stating plainly)
bwrapis Linux-only and this crate is#[cfg(target_os = "linux")], so on my macOS host I could not run the sandbox or the test binary — acargo check --target x86_64-unknown-linux-gnustops inring/aws-lc-sysfor want of a Linux C cross-compiler. What I did verify: the new identity function run verbatim in a standalone harness (two spellings collapse in both orders, hard links stay separate, a missing leaf resolves through the existing ancestor, distinct files never collapse), and the file is rustfmt-clean. The regression testsymlinked_deny_entries_mask_one_file_onceis included, but it needs a Linux host to execute — the CI run on that target is the real gate.Happy to move the dedup into the policy layer instead if you would rather
get_unreadable_roots_with_cwd_preserving_symlinksnot hand out redundant literals; I kept it inbwrap.rsbecause that is where the mask list is placed into phases.- Each mount phase claims the resolved identity of a file before masking it (
Follow-up to #43929. Thanks for the fix in #50059, which works. With it in place, a second failure now shows.
Setup: Linux x86_64, Fedora Atomic (Bazzite), where
/homeis a symlink to/var/home. A managedrequirements.tomldenies single files under both spellings, so the deny holds whichever path a command uses:Result on 0.162.0 (desktop 26.1007.21434, bundled engine 0.162.0-alpha.17.2):
codex sandbox -- /usr/bin/truefails with both system bubblewrap 0.12.0 and Codex's bundled bubblewrap:0.156.1 with the same policy works when given a bwrap without the #43929 descriptor bug.
Cause: the bwrap argument list now holds two masks for one file:
bubblewrap removes each bind-data temp file right after mounting it. The second mount therefore targets a deleted file, and the kernel returns ENOENT. Minimal repro without Codex:
The same happens when one path is simply listed twice. 0.156 built masks from
get_unreadable_roots_with_cwd; 0.162 usesget_unreadable_roots_with_cwd_preserving_symlinks, so aliases are no longer merged, possibly since #48155.Suggested fix: before adding single-file masks, drop any file mask whose resolved target was already masked. Directory masks (
--tmpfs) are not affected.