Repository navigation
fix: make read-only grants read-only below their submounts - #15
Conversation
The namespaces backend binds a read-only grant with MS_BIND and MS_REC. Then it remounts the grant read-only. The remount changes only the top mount. Submounts under the grant stay writable. On WSL2, /usr/lib/wsl/lib and /usr/lib/modules/<kernel> are such submounts under /usr. After the remount, the backend now calls mount_setattr(2) with AT_RECURSIVE. It sets MOUNT_ATTR_RDONLY and MOUNT_ATTR_NOSUID on all mounts in the tree. This applies to each read-only mount in the plan. A kernel older than Linux 5.12 has no mount_setattr. On such a kernel the launch fails with an error that names the cause. The backend does not start a sandbox with a writable submount under a read-only grant. The test mounts a tmpfs under a granted directory in a namespace of its own. It expects EROFS for a write in that tmpfs inside the sandbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A kernel before Linux 5.12 has no mount_setattr. A seccomp profile that does not know the call also returns ENOSYS. In both cases availability() said Ok, and amem doctor passed. Then every launch failed, and the message named only the kernel. The availability probe child now calls mount_setattr with arguments that the kernel refuses for a different reason. If the call returns ENOSYS, availability() returns the same reason that a launch gives. The launch message now names both causes. The test runs availability() and a launch under a seccomp filter that returns ENOSYS for mount_setattr. It expects both to fail with the new message. The read-only test now also expects the submount under a read-only grant to be nosuid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It is security-critical confinement code that relies on raw unsafe mount-namespace syscalls whose correctness depends on kernel behavior that cannot be executed and fully verified in this review environment.
No open issues 🎉
What changed in this PR
This PR closes a confinement gap in the Linux namespaces backend: a read-only bind grant was only read-only at its top mount, so submounts copied in by MS_REC (for example /usr/lib/wsl/lib under /usr on WSL2) stayed writable inside the sandbox when the caller owned them. The fix makes a read-only grant read-only (and nosuid) all the way down, and makes amem doctor report the same precondition the launch requires.
Changes:
- Add
read_only_below()incrates/sandbox/src/linux.rs, invoked frombind()after the read-only remount, which callsmount_setattr(AT_RECURSIVE, {MOUNT_ATTR_RDONLY | MOUNT_ATTR_NOSUID})to apply the flags to the whole subtree; a missingmount_setattrfails the launch rather than silently leaving a submount writable. - Teach
availability()to probemount_setattr(viahas_mount_setattr()) in the child and surface a dedicatedNO_MOUNT_SETATTRmessage so doctor and the launch agree. - Add integration tests covering the recursive read-only/nosuid behavior over a tmpfs submount and the
mount_setattr-absent (ENOSYS seccomp) path, plus a sharedcommontest harness for running probes inside the sandbox.
| File | Description |
|---|---|
crates/sandbox/src/linux.rs |
Adds recursive read-only propagation via mount_setattr, a has_mount_setattr probe, and wires both into bind() and availability(). |
crates/sandbox/tests/read_only.rs |
New integration tests: submount is read-only/nosuid inside, and a missing mount_setattr is reported and refuses launch. |
crates/sandbox/tests/common/mod.rs |
New shared harness to build a Plan and run an ignored probe test inside the namespaces sandbox. |
I reviewed the syscall argument order and mount_attr layout in read_only_below, the size=0 → EINVAL vs ENOSYS logic in has_mount_setattr, the availability() exit-code mapping (0/1/2) and its unchanged Result<(), String> contract used by doctor.rs, and the test harness (including how the launch error reaches stderr through the pre_exec eprintln). I did not find a concrete defect.
Review effort: Balanced
The read-only tests skipped when availability() failed. On CI they skipped without a trace, so they did not test anything there. The new helper common::confinable() calls availability(). If the sandbox cannot run and the CI variable is set, the helper fails the test and gives the reason. If CI is not set, the helper prints the reason and the test skips. Each test that skipped now uses the helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # crates/sandbox/src/linux.rs
The probe calls mount_setattr with a size of zero. A kernel that has the call refuses that size with EINVAL before it checks permission. The probe took every answer except ENOSYS as the call. A seccomp profile whose default answer is EPERM passed the probe, and then every launch failed. The probe now takes only EINVAL as the call. A new test runs the probe under a filter that answers EPERM. Not in this commit, from the same review: - amem doctor files a missing mount_setattr under "user namespaces" and gives the AppArmor fix. main has the same problem for seccomp. A separate change gives each reason its own check and fix. - docs/harness-containers.md still lists this work as open. The docs pull request after these fixes updates it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This PR fixes one of the gaps that the harness-container survey (#2) found in our code: a read-only bind that is not recursive (
docs/harness-containers.md, "A read-only bind that is not recursive").The problem
The Linux backend binds a read-only grant with
MS_BIND | MS_REC, then remounts it read-only. The remount applies to the top mount only. On WSL2,/usr/lib/wsl/liband/usr/lib/modules/<kernel>are separate mounts under/usr, and they stayed read-write inside the sandbox. Only file ownership stopped a write. A submount that the user owns under a read-only grant was writable from the box.The change (
crates/sandbox/src/linux.rs)bind()callsread_only_below(target). This callsmount_setattr(AT_FDCWD, target, AT_RECURSIVE, {MOUNT_ATTR_RDONLY | MOUNT_ATTR_NOSUID}), which sets the flags on the whole tree. It sets flags and clears none, so flags that the kernel locked stay. Every read-only grant goes throughbind(): the system mounts, the image's rootfs, the harness, the memory binary, the projection and the system files.mount_setattr. On a kernel before Linux 5.12, or under a seccomp profile that refusesmount_setattr, the launch fails with this message: "mount_setattr is not available here: it needs Linux 5.12 or later, and a seccomp profile that allows it". It does not fall back to a remount for each mount from/proc/self/mountinfo, because that cannot reach a submount that another mount hides. The cost: a host withoutmount_setattrcannot make a confined launch.--unconfinedstays available.availability(), whichamem doctoruses, now probesmount_setattrwith arguments that the kernel refuses with EINVAL when the call exists. So doctor reports the same reason, and does not pass when the launch then fails.Failing first
cargo test -p dm-sandbox --test read_onlyagainstmain: exit 101.The submount is a tmpfs that belongs to the caller, so ownership cannot be what refuses the write. The test expects EROFS (30).
Against
3708443, before the second commit:mount_setattr:{"availability": "ok", "launch": "refused"}under a filter that answersmount_setattrwith ENOSYS.MOUNT_ATTR_NOSUIDremoved (a mutation):"submount_nosuid": "0"where"1"is expected.make check: 622 tests, 622 passed, 3 skipped. The 3 skipped are probe binaries that the tests run inside the sandbox.Adversarial pass
A fresh agent attacked
3708443. It verified these points:mount_setattrruns before later binds, and the plan order puts the home and the work tree after the system mounts./usrare now read-only and nosuid inside.mount_setattrwith CAP_SYS_ADMIN. The spike's kind profile allows it too.It found two gaps, both fixed in
7ce1da5. Doctor passed wheremount_setattris missing, and the message blamed the kernel even when a seccomp profile was the cause. The nosuid half had no test.Notes for review
main, and this branch hasmainmerged in. Before this change, the tests skipped on CI: in run 40096054625 they reportedokabout 2 ms after the binary started. The evidence above is from a WSL2 host with kernel 6.18.availability(). The merge ofmain(fb89f0f) keeps both checks. The probe child returns 1 for the user namespace, 2 formount_setattrand 3 for seccomp.make checkafter the merge: 643 tests, 643 passed, 6 skipped.Second adversarial pass (after the merge of
main)A fresh agent attacked
fb89f0f. WithCI=1the confined tests ran on a WSL2 host: read_only 2 passed, seccomp 3 passed, launch 20 passed. It found that the merge lost nothing. The probe order (user namespace,mount_setattr, seccomp) matches the order of a launch.It found these problems:
4585a6a. The probe took every answer exceptENOSYSas the call. A seccomp profile whose default answer isEPERMpassed the probe, and then every launch failed. The probe now takes onlyEINVALas the call. A new test runs the probe under a filter that answersEPERM. Against the old probe it failed:left: {"availability": "ok", "launch": "refused"}.amem doctorfiles a missingmount_setattrunder "user namespaces" and gives the AppArmor fix.mainhas the same problem for seccomp. A separate change gives each reason its own check.docs/harness-containers.mdstill lists this work as open. The docs PR after these fixes updates it.make checkafter the fix: 644 tests, 644 passed, 6 skipped.Posted by Claude on behalf of @sstriker