fix: re-assert locked mount flags in the sandbox read-only seal (#8386) - #8629
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix mirroring the standard container-runtime handling of userns-locked mount flags: restriction-only OR, fail-closed path untouched, verified by an in-band kernel repro. [DESIGN-REVIEWED] a9eee78 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks pan out. Final review: First-Principles-Verdict: PASS A cause-level fix at the kernel's actual rule — locked What this change shipsIntent: make sandboxed spawns start on hosts whose
Depth and sibling counts were run, not assumed: the root cause is a kernel constraint (locked flags in unprivileged user namespaces), not an earlier repo choice, and the fix removes it rather than catching the symptom (no EPERM-swallow, no seal skip). Grepped [FIRST-PRINCIPLES-REVIEWED] a9eee78 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
01841b0 to
a9eee78
Compare
Problem / Motivation
On Linux hosts where the filesystem holding a
READONLY_DIRStarget is mountednosuidand/ornodev— the systemdtmp.mountdefault for/tmpon Amazon Linux 2023, Fedora and RHEL, and common for/homeon fleet hosts — the sandbox launcher's read-only seal fails with errno 1 (EPERM)._mount_or_diecorrectly fails closed, so every sandboxed spawn aborts withsandbox: BLOCKED -- sealing read-only path ... errno 1, and 35 backend tests refuse to run on unpatched main because pytest'stmp_pathlives under/tmp(most ofops_mission_control/tests/test_ledger_sync_git.py, 6 inauto_improvement/tests/test_profile_capture.py, 1 each inauto_improvement/tests/test_runner.pyandops_mission_control/tests/test_providers.py). GitHub's Ubuntu runners keep/tmpon the root filesystem without these flags, which is why CI stayed green.Why it matters
Every sandboxed spawn on an affected host is dead on arrival — an availability/portability defect (not an exposure: the failure direction is closed). AL2023/Fedora/RHEL defaults make this the common case on exactly the fleets most likely to run the gateway.
What changed (motivation → approach → change)
Symptom: the seal's second step —
_MS_REMOUNT | _MS_BIND | _MS_RDONLY— is refused with EPERM. Root cause: inside an unprivileged user namespace the kernel treats a mount'snosuid/nodev/noexecbits as LOCKED (MNT_LOCK_*) and rejects any remount whose flag set would clear them; the bind created in step one inherits those bits — locks included — from its source mount, so a remount carrying onlyMS_RDONLYreads as an attempt to drop them.The fix is restriction-only: re-assert the flags the target already carries, so the kernel's locked-flag check passes.
_MS_NOSUID = 2,_MS_NODEV = 4,_MS_NOEXEC = 8next to the other mount-flag constants in the launcher template (src/kiro_crew/sandbox.py)._locked_mount_flags(target): readsos.statvfs(target).f_flagAFTER theMS_BINDstep (so it sees the new bind's effective flags — the mount the kernel checks) and mapsST_NOSUID/ST_NODEV/ST_NOEXECto theirMS_*values viagetattr(os, "ST_*", 0)(theST_NODEV/ST_NOEXECconstants are Linux-only; the extracted helper source also runs in POSIX-wide tests). atime is left alone — a remount passing no atime flag preserves the existing mode, which already satisfiesMNT_LOCK_ATIME. Onstatvfsfailure it falls back to 0 extra flags: the remount then behaves exactly as before and still fails closed via_mount_or_die. The seal is never degraded._mount_or_die's fail-closed contract is untouched; no silent-degrade path was added.docs/system-specs/modules/governance.md(the section describing theREADONLY_DIRSseal).Scope guard honored: no change to the backend probe,
_mount_or_die, or theSENSITIVE_DIRShiding mounts (empty-dir binds are not remounted and are unaffected); #8008's symlinked-HOME/ownership-walk classes are not addressed here.Tests
New
test/test_sandbox_seal_locked_flags.py, following the established extraction pattern (sources under test are pulled from the GENERATED launcher script, so no test can pass against code the launcher no longer contains):ST_NOSUID|ST_NODEV→_MS_NOSUID|_MS_NODEV(and all three bits, and 0 for a plain flag set);statvfsfailure falls back to 0 extra flags; a host missing theST_*constants (macOS) reads them as 0 instead of raisingAttributeError.unshare -Urmmounts a fresh tmpfsnosuid,nodev(inside the namespace; independent of the host's/tmpoptions) and a nested inner namespace, which inherits it locked, runs the shipped seal path. An in-band control first proves the test's power: the pre-fix remount (MS_RDONLYalone) must be refused with EPERM — the exact sandbox read-only seal fails EPERM where /tmp is nosuid,nodev (AL2023/Fedora/RHEL defaults) — 35 backend tests refuse to run #8386 failure — then the fixed path must land and a write must be refused with EROFS. Skips (never fails) where user namespaces,unshare, tmpfs mounting, or flag locking are unavailable, mirroring the existing sandbox tests.Pinning-test updates:
test_sandbox_mount_checked.py'ssite4mutation anchor tracks the new call text and its region namespace gains the three constants;test_sandbox_absent_ceiling_seal.py's extracted-loop globals gain a 0-returning_locked_mount_flagsstub so it keeps asserting the bind+remount pair exactly (the real helper is covered by the new file).Pre-push review: two model-pinned lanes (GPT + Opus mirrors) found 1 blocking + 2 advisory findings (subprocess
cwd=, macOSST_*constants, in-namespace tmpfs not locked); all three fixed and verifier-confirmed closed on this head.Manual verification
Local gates run (isort, flake8, mypy, diff-scoped black/brand/scrub gates) plus a compile-level smoke of the generated launcher and both staged namespace scripts. Test suites run in CI per this host's policy. No host with a
nosuid,nodev/tmpwas reachable from this session, so the 35 previously-failing tests are verified via CI plus the unshare-based nested-namespace regression test, which reproduces the locked-flag EPERM in-band before exercising the fixed path.Screenshots / video
N/A — backend-only change, no UI surface.
Related Issues
Fixes #8386
Pattern harvest
Rule candidate: review-prompt
Pattern: "remount inside an unprivileged user namespace must re-assert the source mount's locked nosuid/nodev/noexec bits; a flag-narrowing remount EPERMs on hosts with hardened mount options"
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)