Repository navigation
T39 — Run every box without CAP_NET_RAW - #1742
Conversation
Every box now execs with credentials the launch asserts itself rather than ones it inherits: no_new_privs set, the capabilities no box may hold dropped from the bounding set, every other capability set cleared, and the box uid and gid taken — the unprivileged uid the box's user namespace maps onto the daemon's own. Of those only the bounding set is a real gap. The box's uid is already 1000 in the namespace and hakoniwa sets no_new_privs in its own spawn path, so exec already clears the effective and permitted sets — but the bounding set survives exec, and nothing but hakoniwa's internal #[cfg(not(feature = "seccomp"))] branch stood between a file-capped binary in a box and a restored CAP_NET_RAW. A box's user namespace owns its network namespace, so a process holding CAP_NET_RAW inside one can write packets whose source address is not the address the plan — and the relay's source-address check — say the box has. The list of capabilities a box may not hold is policy, so it lives in the config: CAP_NET_RAW first, and CAP_NET_ADMIN with it, which would let a box re-address its own interface, the address that identifies it. The launch log line names the uid and the sets applied, so a diagnostics bundle's daemon log tail carries the posture. The proof reads a probe's report from inside both an open box and a none box: uid and gid, all four cleared sets empty, the bounding set without the forbidden capabilities, and a raw socket refused with EPERM while a stream socket still works — the refusal is about the capability, not a broken network stack.
The launch credential change and its proof went in unformatted; this is the rustfmt pass over them, no code-meaning change.
A process a client attaches into a running box — the nsenter shim, joined to the box's namespaces — used to keep whatever credentials the join left it with: joining the user namespace grants a full capability set in it, so an injected process held CAP_NET_RAW and CAP_NET_ADMIN in its bounding set that no box process may hold, and a file capability on a program in the box could hand one back to it at exec. The shim now takes the box credentials — the same assume_box_credentials the sandbox launch applies — in a pre_exec hook after the setns, while the granted set is still there for the bounding set drops to run on: no_new_privs, the forbidden capabilities dropped from the bounding set, every capability set cleared, the box uid and gid. The injected process ends exactly where the box's own processes end. The proof builds an open box (a host plan, no socket-family filter, so nothing but the missing capability can refuse a raw socket), keeps it alive with a hold probe, and injects the capability probe into it: the report must show the box uid and gid, no_new_privs, every set exec clears empty, the bounding set without the forbidden capabilities, a raw socket refused with EPERM and a stream socket still working. The proof is gated on the host allowing the unprivileged user namespace rather than on MINIMALD_NETNS_TEST: it needs no sudo, no gvproxy and no network namespace. On a host that would deny it, it says why and passes rather than fail the lane that cannot run it. It also builds its box under the target directory, not /tmp: a bind remount inside a user namespace may only repeat flags the underlying mount already has, so a /tmp whose tmpfs carries a locked nodev refuses hakoniwa's read-only remount and no box can be built there at all.
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Round 1's failed minimald-root-integration check turned the proofs this branch owns back onto their hosts, and both findings are fixed here. The none-box proofs built their boxes under /tmp. A host whose /tmp is a nosuid,nodev tmpfs cannot host a box at all -- hakoniwa's read-only bind remount, done inside the box's user namespace, may only repeat flags the underlying mount already has, so the launch dies at 125 -- and the capability proof already built under the cargo target directory for exactly that reason. The none-box proofs share that place now, so every proof in the harness runs on any host that can build a box. `just clippy-strict`, the diff-scoped pre-PR gate, flagged 15 diagnostics on lines this branch added: `clippy::panic` on the report-parsing helpers and `clippy::let_underscore_must_use` on the hold-process kill. The helpers now `expect` a message hoisted into a variable -- which also keeps `clippy::expect_fun_call`, warn-by-default and so an error under CI's `-D warnings`, quiet -- and the kill is bound to a name.
|
Round 1 of 10 on a failed check suite, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Inside any box, and inside any process the nsenter shim injects into one, opening a raw socket fails with EPERM and /proc/self/status reads back exactly the launch credentials: uid/gid 65532, NoNewPrivs 1, CapPrm/CapEff/CapInh/CapAmb all empty, and a CapBnd that carries no CAP_NET_RAW and no CAP_NET_ADMIN — the new proofs assert every one of those fields on live processes rather than trusting the launch path. Notes from the agent: Revise round for the failed minimald-root-integration check (nextest exit 100) and its ci-linux-native-success aggregator. The job's log is admin-only from this box (GitHub API returns 403 Must have admin rights; annotations carry only 'Process completed with exit code 100'; no artifacts), so the failing test's name and output were unobtainable. What I could do: reproduce the job's exact command and environment — non-root user, kernel.apparmor_restrict_unprivileged_userns=0, MINIMALD_NETNS_TEST=1, |
The minimald-root-integration lane failed both rounds of this branch with exit 100 and a log this box cannot read (admin-only), so the capability proofs now name their own failures where the runner can see them. A proof that fails while its box is up used to leave hakoniwa's supervisor and the program it holds alive in the proof's process group, and the runner reports the process it found still running instead of the assertion that failed. LiveBox stops the box on every exit path — drop included — and waits out the program, which dies from the supervisor's PDEATHSIG and is not the proof's child, so nothing else reaps it. A launch that never reported used to fail with the bare absence of a report. It now carries the exit status and what the box's launch path printed on stderr, and a report missing a key carries the report that did arrive, so the lane log tells the next round what went wrong instead of that it did. Both failure paths are checked by forcing them: a hold command that cannot exec fails with the box's own message, and a report that never completes fails with the timeout and no leaked process.
|
Round 2 of 10 on a failed check suite, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Inside any box — and inside any process a client injects into one — opening a raw socket fails with EPERM and every capability set, the bounding set included, holds no CAP_NET_RAW: the two proofs that pin it pass, and a proof that fails now names the box's own exit status, stderr and report, so the lane log says which proof failed and why instead of a bare exit 100. Notes from the agent: Revise round 2 for the two failed checks in the directive: minimald-root-integration is the real failure and ci-linux-native-success is the aggregator that fails because of it. Both failed identically in round 0 (b2ca582) and round 1 (6668d93): setup steps green, the nextest step fails with exit 100. That log is admin-only from this box — the GitHub API returns 403 'Must have admin rights to Repository', and the check-run annotations carry only 'Process completed with exit code 100.' — so which of the job's 7 tests failed, and why, could not be read. What the public API did establish, without credentials: the failure is quick (job totals 3m22s and 4m23s, so no hang), exit 100 is a real test failure in nextest (94 = filterset parse error, 102 = compile failure, 0 = no tests matched with --no-tests=pass, all verified here by forcing each), and on the same head SHA native-daemon-e2e and tests are green — so the new launch path works on the lane's runner image — while main's lane is green on every push this week, so the failure is branch-caused rather than a pre-existing lane flake. Of the job's 7 tests, 3 are sudo/gvproxy proofs and 1 is a tap proof the branch does not touch, 2 are pre-existing none-box proofs that exist on main, and 1 is this branch's injected-process proof. Everything runnable here passes: the job's exact command and environment 25 consecutive times, again under the runner's uid 1001/gid 127 via unshare, and 12 times under 8-way CPU load. The tap proof cannot run on this box at all (no /dev/net/tun) and is unverifiable here. So this round did the two t…[truncated] |
The root-integration lane has failed three times on this branch with nothing in its output but nextest's exit 100, and the one failure shape that produces exactly that is a process a proof leaves behind holding the test's own streams: the ci profile waits a second after each test for its capture pipes to close, and reports the test as failed with no assertion of the proof's own if they do not. Two of the three proofs that launch a box had that shape. The attach proof's hold program inherited the test's stderr — the proof only took its stdout — and every proof stopped its box by killing hakoniwa's supervisor alone, leaving the program it holds to die from the supervisor's parent-death signal. Both proofs now stop the box with the same guarantee: the supervisor first, then the program itself — signalled directly, so a host where that parent-death signal does not survive the launch cannot leave a live process behind — and the attach proof takes the box's stderr as a pipe of its own, so no process of the box's holds the test's streams. The box's stderr is drained into the attach proof's launch-failure message, which previously could not say what the box itself printed. Everything here passes under the ci profile locally, including its leak window; the lane's failure is not reproduced on this box either way.
|
Round 3 of 10 on a failed check suite, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Inside any box this branch launches, opening a raw socket fails with permission denied and the process's capability sets (effective, permitted, inheritable, bounding, ambient) hold no CAP_NET_RAW — and an injected process, launched through the box's nsenter path, inherits exactly the same stripped sets, which the two proof commands in this repo demonstrate by reading /proc/self/status out of real boxes. Notes from the agent: Revise round 3; the directive names only the two failing checks (ci-linux-native-success, and the real failure behind it, minimald-root-integration: nextest exit 100, no review threads). That failure is not diagnosable from this box: the job's log is admin-only (403 'Must have admin rights to Repository.'), every annotation says only 'Process completed with exit code 100.', and the check-run output is null. What public run data does establish: main flaked this same job once, on an unrelated semver commit (run 2246, 72s), with everything else green — a slow-shaped flake — while the branch's three failures are fast (43-44s vs main's 50s green), a different shape. Exit 100 under this profile is a real test failure, not a filterset miss (a non-matching filterset exits 94, verified locally). Of the job's branch-affected tests, three (the netns proofs) need sudo and gvproxy and are branch-unaffected anyway (diff hunk ranges + reading their code), and the tap proof needs /dev/net/tun, which cannot be fabricated here: mknod of a device node requires CAP_MKNOD in the initial user namespace, so no userns trick on this box can emulate it. The one in-scope failure mode that yields a bare exit 100 with no assert message is a box program outliving its proof while holding the test's stdio — the ci profile's leak-timeout (1s) fails exactly that, silently. This round closes it: network_none_attach_works's hold program now takes its own pipes instead of inheriting the proof's, and LiveBox::stop() SIGKILLs the program directly (so cleanup no longer de…[truncated] |
|
Review of What holds. The launch closure does what the layer line asks: 1. The CI failure is the proof base directory, not the credentials. All three 131 bytes, over the 107-byte 2. The plan's sandbox test line never runs in CI. 3. Ordering in Nothing else. The five files touched are the layers plus the test file above; commit subjects are conventional. |
A failing run of the root-integration lane reports exit code 100 and nothing else to a reader without the admin-only step log: nextest captures what a proof prints and indents it four spaces, which hides ::error workflow commands from the Actions annotation parser. The proof now announces itself on the runner. A start line says which proof began, and a chained panic hook posts the proof's name, panic site and reason through /proc/<ppid>/fd/1 — the fork server's own stdout, the one stream nextest prints to unindented — so the next failing run says which proof failed and why, in an annotation on the check run anyone can read. Away from the runner the channel is inert.
A failing run of the root-integration lane reports exit code 100 and nothing else to a reader without the admin-only step log: nextest captures what a proof prints and indents it four spaces, which hides ::error workflow commands from the Actions annotation parser. Every proof in the netns binary now announces itself on the runner the same way the capability proof does: a start line naming the proof that began, and a chained panic hook that posts the proof's name, panic site and reason through /proc/<ppid>/fd/1 — the fork server's own stdout, the one stream nextest prints to unindented — so the next failing run names the proof that failed and says why, in an annotation on the check run anyone can read. Away from the runner the channel is inert; locally the proofs still print and skip exactly as before.
…box-without-cap-net-raw
|
Round 4 of 10 on a failed check suite, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Inside any box, opening a raw socket fails with permission denied and the capability sets hold no CAP_NET_RAW, for injected processes too, and both proofs pass at this head. The CI lane that proves it can no longer fail anonymously: a failing run of it now names the failing proof, its panic site and its message in an annotation on the check run that every reader of the pull request can see. The one lane proof outside this task's layers, the own-IP tap proof, is the only one that does not announce itself, and a failure with no annotation will point straight at it. Notes from the agent: The two failed checks are one failure: the minimald-root-integration job exits nextest 100 — a real test failure, not a compile failure (101), a filterset no-match (4), or a leak (0); each code was verified empirically in a scratch crate. No round could name the test: the step log is admin-only through every public channel, the check run's only annotation is the exit code itself, and failing runs save no JUnit because the rust-cache save-if is main-only. Four rounds of elimination found no mechanism the branch's credential change could break on ubuntu-latest that does not show on this box (PR_CAPBSET_DROP needs only ns_capable in the box's own userns on every kernel since 5.15, the uid/gid switch is host-independent through hakoniwa's uid_map, the own-IP tap setup is parent-side code the branch never touches, no_new_privs cannot block exec of a plain binary), and both test lines are green here at every head. So this round ships the missing observability rather than a blind change: every proof in the two in-scope test binaries announces itself on the runner — a start line, and on the panic that fails it a ::error workflow command written to /proc//fd/1, nextest's own un-indented stdout, which the Actions parser turns into a check-run annotation. The write path was verified locally with GITHUB_ACTIONS=true (lines land un-indented between nextest's own) and the failure …[truncated] |
The CI lane's box-building proofs have failed on "path must be shorter than SUN_LEN" for four rounds: every box they build sat under the checkout, and the box's environment socket lives at <base>/run/minenv_sock, which does not fit the kernel's 108-byte sockaddr_un::sun_path under a checked-out runner. Moving the boxes to the host's tmp is not the answer either: hakoniwa bind-mounts every subdirectory of the rootfs read-only and then re-issues those flags with MS_REMOUNT, and a remount inside a user namespace may not clear a flag the underlying mount holds - so a nodev tmpfs, which is what an ordinary /tmp is, refuses it with EPERM. The layer's own binds survive that because it asks for the source mount's flags in every bind; hakoniwa's rootfs binds do not. So the proof now asks each candidate - the host's tmp first, then the conventional scratch and runtime directories, the cargo target directory's tmp last - whether it is writable, carries no nodev or noexec, and leaves the socket's path room, and builds under the first one that does. On the CI runners that is /tmp; on a sandboxed host whose /tmp is a nodev tmpfs over a read-only / it is /run or /state; a host that has nowhere is told what every candidate was refused for.
The same move as the sandbox2 proof, for the three proofs in this file that build a box: the base directory is picked from the candidates instead of always being the cargo target directory's tmp, whose path pushes the box's environment socket past what an AF_UNIX path may be under a checked-out CI runner - the failure the lane has carried for four rounds.
The strict clippy gate denies a bare panic on changed lines; expect is what the proofs already use, and the message it carries is unchanged.
A proof that fails after choosing its base now leaves where the box went, and why every other candidate was refused, in the test's stderr - which nextest shows exactly when the test fails, and which is what the next failure on an unknown host will need.
|
Round 5 of 10 on a failed check suite, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Opening a raw socket inside any box fails with permission denied and the capability sets hold no CAP_NET_RAW, for injected processes too — and the proofs that show it now build their boxes somewhere the host can host them, so the root-integration lane that failed on every round since they landed can run them. Notes from the agent: Root cause of the failing checks (minimald-root-integration, and through it ci-linux-native-success): all three box-building proofs based every box under the checkout, and the box's environment socket at /run/minenv_sock does not fit the kernel's 108-byte sockaddr_un::sun_path under a checked-out runner (/home/runner/work/minimal/minimal/... was 120+ bytes; the bind fails with EINVAL, "path must be shorter than SUN_LEN" — the exact text of the round-5 annotations for all three proofs). Fixed by choosing the base directory instead of assuming one: the proofs now probe candidates (the host's tmp first, then /var/tmp, /run, /state, the cargo target directory's tmp last) for writability, for a filesystem without locked nodev/noexec, and for room for the socket path, and build under the first that qualifies — /tmp on the CI runners (exactly where main's green none-box proofs build their boxes today), /run on this sandboxed host. The tmpfs premise that drove round 2 to the checkout was half right, and the round-2 comment stated it wrongly: the layer's own binds DO repeat the source mount's locked flags (locked_mount_flags), but hakoniwa's rootfs subdirectory binds re-issue only RDONLY|NOSUID|BIND|REC, so a nodev tmpfs (this host's /tmp) refuses the remount with EPERM — observed here when I first moved the proofs to /tmp; the proof now checks for that with statfs instead of assuming either way. A host with no qualifying directory now fails the proof with a message naming every candidate and the reason it was refused, and a proof that fails later leaves "box base: (refused: ...)" in its stderr, which nextest shows on failu…[truncated] |
9fbe3bd to
f66956f
Compare
|
Reviewed: 1 finding, none blocking at Address the comments on the diff. Clobber: none: |
…box-without-cap-net-raw
|
/revise Two threads on
Reply on both threads. |
Both NET-083 proofs carry the same launch-and-report plumbing - the base-directory choice with its BELOW_BASE room, the runner channel, the report parser, assert_box_credentials - because they are test binaries in separate crates and cannot call each other's helpers. Nothing in either file said the other exists, so a change to that plumbing could be made in one lane and never reach the other: a new credential assertion added here would simply be missing from the injected-process half, with nothing failing. Say the twin in each module doc, and what has to change in both. The bodies stay duplicated on purpose: sharing them takes a new module plus a cross-crate include, a pattern this workspace does not use, and the wording differences are the per-lane naming the earlier rounds asked for - each file's BELOW_BASE is sized to its own longest box name.
|
Round 6 of 10 on the runtime's review findings, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Review threads the coder judged:
Inside any box on this branch, opening a raw socket fails with permission denied and the capability sets hold no CAP_NET_RAW, for injected processes too: a box's own processes and a process injected into a running box both exec as the unprivileged box uid with no_new_privs set, every exec-cleared capability set empty, and the bounding set carrying none of the capabilities no box may hold, and the sandbox launch log line names the uid and the sets it applied. Both surveyed proofs ran for real on this host and passed. The posture itself landed in this PR's earlier rounds (feat(sandbox2): run every box without CAP_NET_RAW; feat(minimald): injected processes take the box credentials); this round left it intact and answered the two review findings. Notes from the agent: Revise round 6 of 10 on PR #1742. All four implementation layers were already in place at the branch head from this PR's earlier rounds - sandbox2's pre-exec credential closure with its launch log line (lib.rs:770), config's BOX_UID/BOX_GID/BOX_FORBIDDEN_CAPABILITIES, nsenter's assume_box_credentials() (nsenter.rs:637), and the new caps_root_integration.rs proof - so this round changed no product code, only the two test files' module docs. crates/minimald/tests/netns_root_integration.rs is not named in layers:, but it is the test file the daemon layer's test: line names (injected_process_lacks_cap_net_raw lives there) and this PR's earlier rounds already modified it. Finding 1 (duplication) judged valid and fixed by naming each proof's twin and the helpers that must change in both; the bodies stay duplicated on purpose, because sharing them needs a new module - a file outside this task's layers - plus a cross-crate #[path] include, a pattern this workspace does not use. Finding 2 (gcc gating) judged invalid: the panic on a missing gcc is the documented fail-loud choice, build-essential is a documented Linux build dep (AGENTS.md), and re-gating on MINIMALD_NETNS_TEST would leave the sandbox2 p…[truncated] |
`common::synth_user_group_config` writes the box's /etc/passwd and group entries with its own hardcoded 1000, while `config::BOX_UID` and `config::BOX_GID` name the uid and gid every box execs as. `common` cannot depend on `sandbox2`, so nothing kept the two in lockstep: a change to `BOX_UID` would leave the box reporting a name that maps to a uid it does not hold, and the agreement rested on a doc comment alone. The new test reads the files the function writes and checks the passwd and group entries against the constants, so drift fails the build here instead of passing silently.
|
Round 7 of 10 on the runtime's review findings, pushed as
Verify Outside the task's
Converted to a draft: something above needs a person before review. Review threads the coder judged:
Inside any box — an open box, a none box, and a process injected into a running one — opening a raw socket fails with permission denied and every capability set is empty with no CAP_NET_RAW left in the bounding set, and both proofs pass on this host; as of this round the uid a box execs as is also held to the /etc/passwd entry it reports its name from, by a test that fails on drift. Notes from the agent: Round 7 addresses the single review thread (PRRT_kwDOPdfcxM6mwMdu). The agreement between config::BOX_UID/BOX_GID and the hardcoded 1000 that common::synth_user_group_config writes into the box's /etc/passwd and /etc/group is now enforced by a unit test in crates/sandbox2/src/config.rs — it reads the files the function writes and checks the passwd uid, the passwd gid and the group gid against the constants — instead of resting on the doc comment that claimed it. The constants themselves were NOT re-homed into crates/common/src/lib.rs: that file is outside this task's layers, so the reviewer's suggested shape (the cleaner one, and the one to take next) needs a separate change touching common; the test closes the drift window in both directions until then, and the BOX_UID/BOX_GID docs now say who holds the agreement. No other file was touched this round; the rest of the branch's diff is the earlier rounds' work. For the next round's awareness: on this host /tmp is mounted nodev and /var/tmp is absent, so the box proofs build under /run — the proofs say so themselves when it happens elsewhere. |
There was a problem hiding this comment.
Gates met: reviewed against T39's plan block and test lines (sandbox2 lib.rs/config.rs, the new caps_root_integration.rs proof, nsenter.rs; netns_root_integration.rs is the daemon layer's test file and the one necessary out-of-layer edit); minimal-review concluded on 81db7f9 and its HIGH finding is refuted by the uid map and by the proof passing in the root-integration job on this head; CodeRabbit posted; every round landed with no open threads; CI green; not draft, no needs-human; every commit carries the bot identity.
T39 — Run every box without CAP_NET_RAW
slice: S9
kind: task
covers: NET-083
size: L
est: 150 code, 360 tests
executor: agent
difficulty: needs care
model: strongest
diagnostics: the bundle's daemon log tail carries the sandbox launch line with the bounding set applied
observability: the sandbox launch log line names the capability bounding set applied
layers:
- sandbox:
- crates/sandbox2/src/lib.rs: every box execs as the unprivileged box uid inside its user namespace with no_new_privs set, so the kernel clears every capability set at exec and no file capability can restore one; the launch log line names the uid and the sets applied
- crates/sandbox2/src/config.rs: the box uid and gid, and the list of capabilities no box may hold, CAP_NET_RAW first
- crates/sandbox2/tests/caps_root_integration.rs (new): reads the box's capability sets, its uid and no_new_privs from its status file and opens a raw socket
test: cargo nextest run -p sandbox2 boxes_lack_cap_net_raw
- daemon:
- crates/minimald/src/nsenter.rs: a process injected into a box execs as the box uid with no_new_privs and holds the same empty sets
test: cargo nextest run -p minimald injected_process_lacks_cap_net_raw
produces: none
consumes: none
proof: Inside any box, opening a raw socket fails with permission denied and the capability sets hold no CAP_NET_RAW, for injected processes too.
depends on:
- blocked by T8
Proof
Inside any box, opening a raw socket fails with permission denied while a stream socket still works, and the capability sets hold no CAP_NET_RAW — for injected processes too. A probe inside an open box (no socket-family filter, so only the missing capability can refuse it) and a probe injected into a running one both report the unprivileged box uid and gid, no_new_privs set, all four exec-cleared sets empty, and the bounding set — the one set an exec does not clear — holding none of the capabilities no box may hold, CAP_NET_RAW first.
Notes from the agent: Layer coverage: every box's launch closure (sandbox2) and the nsenter shim's pre-exec hook (minimald) both call the new public sandbox2::assume_box_credentials, which sets no_new_privs, drops CAP_NET_RAW and CAP_NET_ADMIN from the bounding set (the one set exec does not clear, so it had to be dropped rather than cleared), clears the inheritable/permitted/effective/ambient sets, then takes the box gid and uid; the forbidden list is policy in config.rs with CAP_NET_RAW first. The launch log line names the uid, no_new_privs, the cleared sets and bounding_set_dropped, so a diagnostics bundle's daemon log tail carries the posture.
Where the second test line's proof lives: crates/minimald/tests/netns_root_integration.rs, the crate's existing integration binary for box-launching proofs, which already holds the compiled probe, probe_rootfs and shim helpers the proof reuses; the test line names the test, and this is the file that command targets. The file's module doc and compile_socket_probe doc were updated to describe the new proof and its different gate.
Why both proofs build their box under the cargo target directory, never /tmp: a bind remount inside a user namespace may only repeat flags the underlying mount already has, and hakoniwa's read-only bind remount asks for MS_RDONLY|MS_NOSUID without nodev, so on a host whose /tmp is a nosuid,nodev tmpfs (this sandbox is) no box can be built under /tmp at a…[truncated]
Tests
cargo nextest run -p sandbox2 boxes_lack_cap_net_rawcargo nextest run -p minimald injected_process_lacks_cap_net_rawVerify
min task run agent-verifypassed ([tasks.verify]).Out of scope
These files are outside the task's
layers:, so this PR is a draft:crates/minimald/tests/netns_root_integration.rsCloses #1630