Skip to main content

capsule_runtime/
sandbox.rs

1//! Kernel-level enforcement for the shell-subprocess tree spawned by `shell::execute_shell`.
2//!
3//! Today `execute_shell` is gated only by app-level checks (`policy.shell_allow` string
4//! equality, and `shell::build_shell_env`'s synthetic-HOME/credential-stripping). Nothing
5//! stops the spawned process — or anything it execs/forks — from making arbitrary syscalls:
6//! reading files outside its workdir, or connecting to hosts outside `policy.network_allow`.
7//! This module closes that gap for the shell subprocess tree specifically, using two Linux
8//! kernel primitives:
9//!
10//!   - **Landlock `Execute` rights** to allowlist exec by *path*, decided by the kernel on the
11//!     path it resolved itself. Each `shell.allow` binary — plus its ELF interpreter and its
12//!     `DT_NEEDED` closure — gets a narrow read+execute grant (`resolve_landlock_grants`), and the
13//!     capsule's own workdir gets `Execute` **only** when the manifest declares
14//!     `capabilities.filesystem.workdir_exec: true` (`linux_enforce::workdir_access_rights`). With
15//!     the default, nothing the capsule writes into its workdir can run under any name, so the
16//!     rename-to-an-allowlisted-basename bypass has no path left. `execve`/`execveat` are
17//!     ordinary allowed syscalls, decided entirely by the Landlock domain they run in.
18//!   - **classic seccomp-bpf argument matching** (`SECCOMP_RET_ERRNO`) on `socket(2)`'s `domain`,
19//!     to refuse whole address families outright — see `denied_socket_domains`. Unlike a
20//!     destination `sockaddr`, `domain` is an integer in a register, so the kernel's own BPF can
21//!     compare it with no userspace round-trip and no notification at all. This is what stops a
22//!     capsule reaching `/var/run/docker.sock` (host root) over `AF_UNIX`, which no other
23//!     mechanism here covers: a network namespace does not mediate `AF_UNIX` at all, and Landlock
24//!     ABI v1 does not mediate an abstract-namespace socket. `AF_NETLINK`/`AF_PACKET` are denied
25//!     unconditionally; `AF_UNIX` is denied unless `capabilities.network.unix_sockets` says
26//!     otherwise.
27//!   - **a default-deny syscall allowlist** modelled on the OCI/Docker default seccomp profile —
28//!     see `SECCOMP_SYSCALL_ALLOWLIST`. The filter's default action is `SECCOMP_RET_ERRNO(EPERM)`,
29//!     so a syscall named by neither that array nor one of the rules above is refused outright,
30//!     with no argument inspection at all. This is what puts `io_uring_*` (historically an
31//!     LSM-path-hook bypass, and so a candidate route around Landlock itself), `bpf`,
32//!     `userfaultfd`, `perf_event_open`, `ptrace` and the rest of `SECCOMP_MUST_STAY_DENIED` out
33//!     of a capsule's reach. The filter also sets `SCMP_FLTATR_CTL_LOG`, so each denial reaches
34//!     the kernel audit trail with a syscall number, pid and comm.
35//!   - **Landlock LSM** to scope filesystem access to the capsule's `workdir`. The workdir's own
36//!     grant deliberately withholds `MakeChar`/`MakeBlock`/`MakeSock`, and withholds `Execute`
37//!     unless `capabilities.filesystem.workdir_exec` is declared — see
38//!     `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC` and `linux_enforce::workdir_access_rights`.
39//!     Outside the workdir, alongside the derived
40//!     read+execute grants, a fixed three-device set is granted unconditionally — see
41//!     `CAPSULE_DEVICE_GRANTS`: `/dev/null` read+write (the only writable path outside the
42//!     workdir), `/dev/zero` and `/dev/urandom` read-only, and no other device at all.
43//!
44//! Both Linux tiers additionally strip the forked child's **Linux capabilities** (bounding set,
45//! then permitted/effective/inheritable, then `no_new_privs`) before `execve()` — see
46//! `linux_enforce::drop_all_capabilities`. Landlock and the capability model are independent,
47//! both-must-allow gates: a root-uid capsule keeps `CAP_MKNOD` no matter what Landlock permits,
48//! so the Landlock narrowing above does not subsume this.
49//!
50//! Both are Linux-only. macOS (and any other non-Linux target) has no equivalent kernel
51//! primitive and permanently falls back to the existing, unmodified synthetic-HOME/env-
52//! stripping mechanism in `shell.rs` — see `EnforcementTier::EnvironmentOnly`.
53//!
54//! ## Four-tier model
55//!
56//! - `KernelSealed` (Linux, everything `KernelFull` has, plus a usable unprivileged user+mount
57//!   namespace — AppArmor out of the way, `unshare` + `mount` verified by a real probe): the
58//!   subprocess tree additionally runs inside a private mount namespace pivoted onto a composed
59//!   root, so paths outside it are *absent* rather than denied. Landlock and seccomp still install,
60//!   inside the new root, as defence in depth — see [`crate::sealed`]. Only capsules that declare
61//!   `capabilities.containment: sealed` get this; see `applied_tier`.
62//! - `KernelFull` (Linux, Landlock ABI available — kernel 5.13+): seccomp syscall allowlist +
63//!   socket-domain denial + Landlock filesystem scoping, which is also what makes
64//!   `capabilities.shell.allow` enforceable (exec is a Landlock right, not a syscall filter).
65//! - `KernelSeccompOnly` (Linux, Landlock unavailable — kernel <5.13): seccomp syscall allowlist +
66//!   socket-domain denial only. Filesystem scope stays convention-only (`current_dir`) — a
67//!   documented gap, not a bug — and, since the exec supervisor was retired, so does
68//!   `shell.allow`: with no Landlock domain there is no kernel-level exec mediation on this tier
69//!   at all. `W-SEC-002` says so. The syscall allowlist and the socket-domain denial are identical
70//!   on both tiers: they are seccomp rules, so they do not depend on Landlock in any way.
71//! - `EnvironmentOnly` (macOS / any non-Linux target): no kernel primitive attempted at all.
72//!   Permanent, not a placeholder for a future slice.
73//!
74//! Tier detection is always a runtime capability probe (attempt Landlock ruleset
75//! construction, inspect the resulting `RulesetStatus`; fork a child and really call
76//! `unshare(CLONE_NEWUSER | CLONE_NEWNS)` followed by a `mount(2)`) — never a hardcoded
77//! kernel-version string parse, which is fragile against distro backports, and never a config
78//! file. Each reading is taken live, and a staged session takes exactly one for every consumer
79//! of that session to read; see [`HostProbe`].
80//!
81//! ## Fail-closed invariant
82//!
83//! If kernel enforcement setup fails unexpectedly on a Linux host (not the expected
84//! "Landlock unsupported, degrade to `KernelSeccompOnly`" signal, but something like seccomp
85//! filter install failing, or the namespace-socket handshake failing before spawn) —
86//! `prepare_enforcement` returns `Err` and `execute_shell` must not call `.spawn()` at all.
87//! There is no code path where a Linux host silently runs a shell subprocess with zero
88//! enforcement because setup failed.
89
90use std::net::IpAddr;
91use std::path::{Path, PathBuf};
92use std::sync::Arc;
93
94use murmur_artifact::security_warnings::{
95    security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
96};
97use murmur_artifact::InterpreterRuntimeGrant;
98
99use crate::types::CapabilityPolicy;
100
101/// Kernel-enforcement tier for the current host, in descending order of enforcement
102/// strength. Always host-probed at launch time — never sourced from the manifest.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub(crate) enum EnforcementTier {
105    /// Linux, and everything `KernelFull` has *plus* a private mount namespace pivoted onto a
106    /// composed root (see [`crate::sealed`]): the shipped AppArmor profile is loaded (or AppArmor
107    /// imposes no unprivileged-userns restriction), a real `unshare` + `mount` probe succeeded,
108    /// and the Landlock ABI is usable. Paths outside the composed root are *absent*, not merely
109    /// denied — and Landlock and seccomp still install inside it, as defence in depth.
110    ///
111    /// Ranked first because it is the strongest tier; the enum is documented weakest-last and
112    /// carries no `Ord`, so this position is documentation, not a comparison.
113    KernelSealed,
114    /// Linux, Landlock ABI available (kernel 5.13+): seccomp exec/network allowlisting
115    /// + Landlock filesystem scoping.
116    KernelFull,
117    /// Linux, Landlock unavailable (kernel <5.13): seccomp exec/network allowlisting
118    /// only. Filesystem scope stays convention-only (`current_dir`) — documented gap.
119    KernelSeccompOnly,
120    /// macOS (or any non-Linux target): no kernel sandboxing primitive exists. This is
121    /// PERMANENT, not a placeholder for a future slice.
122    EnvironmentOnly,
123}
124
125/// Pure decision: given whether the host is Linux, the outcome of a Landlock
126/// ruleset-restriction probe, and what [`crate::sealed::probe_sealed_support`] found, decide the
127/// tier. No syscalls here — fully unit-testable on any OS.
128///
129/// `KernelSealed` requires the whole conjunction — Linux, a usable Landlock ABI, AppArmor out of
130/// the way, and a namespace probe that really created one. Any missing element falls back to the
131/// tier that element still supports; nothing here ever reports a mechanism the host did not
132/// demonstrate.
133pub(crate) fn tier_from_probe(
134    is_linux: bool,
135    landlock_fully_enforced: Option<bool>,
136    sealed: crate::sealed::SealedProbe,
137) -> EnforcementTier {
138    if !is_linux {
139        return EnforcementTier::EnvironmentOnly;
140    }
141    match landlock_fully_enforced {
142        Some(true)
143            if sealed.userns_grant.permits_userns()
144                && sealed.namespace == crate::sealed::NamespaceProbe::Ok =>
145        {
146            EnforcementTier::KernelSealed
147        }
148        Some(true) => EnforcementTier::KernelFull,
149        Some(false) | None => EnforcementTier::KernelSeccompOnly,
150    }
151}
152
153/// The tier this *session* actually applies, which is never stronger than the host can back and
154/// never stronger than the capsule asked for.
155///
156/// A capsule declaring `scoped` on a sealed-capable host keeps running exactly as it does today:
157/// the composed root would otherwise silently delete host paths its `interpreter_runtime` grants
158/// legitimately point at — a narrowing of `scoped`'s behaviour, not added security. The
159/// declared floor is honoured, not merely met.
160///
161/// It is deliberately *not* symmetric with the achieved class recorded in the trace: `achieved`
162/// answers "what can this host back" (a host fact) and is probed independently, while this answers
163/// "what did this session install" (a session fact).
164pub(crate) fn applied_tier(
165    host_tier: EnforcementTier,
166    declared: murmur_artifact::ContainmentClass,
167) -> EnforcementTier {
168    match host_tier {
169        EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
170            EnforcementTier::KernelFull
171        }
172        other => other,
173    }
174}
175
176/// The host facts the tier decision is made from, taken together as one reading.
177///
178/// A staged session takes exactly one of these and every consumer of that session reads it — the
179/// containment-floor refusal, the `ScopeReport` written to the trace, and the tier
180/// [`ShellEnforcement::resolve`] installs. Two readings of a host that changed between them would
181/// let the trace describe a session that never ran under what it claims.
182#[derive(Debug, Clone, Copy)]
183pub struct HostProbe {
184    landlock_fully_enforced: Option<bool>,
185    sealed: crate::sealed::SealedProbe,
186}
187
188/// Probes taken since this process started. See [`HostProbe::probes_taken`].
189static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
190
191impl HostProbe {
192    /// Reads the host as it is right now: on Linux a real Landlock ruleset restriction attempt
193    /// (`RulesetStatus::FullyEnforced` → `Some(true)`, `PartiallyEnforced`/`NotEnforced` or any
194    /// construction error → `Some(false)`, no Landlock ABI → `None`) plus a real
195    /// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` and `mount(2)`. Off Linux it probes nothing and
196    /// claims nothing.
197    ///
198    /// Taken once per staged session, not once per process. That is affordable because both
199    /// halves do their work in a forked child and leave the calling process exactly as they found
200    /// it — `linux_enforce::probe_landlock_full_access` forks so the runtime is never placed
201    /// into a Landlock domain by a capability check, and [`crate::sealed::probe_sealed_support`]
202    /// forks because `unshare(CLONE_NEWUSER)` is irreversible for the task that calls it. Neither
203    /// is idempotency-sensitive, so a reading costs no more than the syscalls it makes: two
204    /// `fork`/`waitpid` pairs and three small `/proc` reads.
205    ///
206    /// The window this leaves open is one session wide. Between `runtime::stage_session` and the
207    /// last subprocess that session spawns the host is not re-read; a change inside that window is
208    /// caught by the mechanism installation itself failing closed, not by a second probe. A
209    /// process that stages repeatedly — `mur eval`, which stages once per dataset case — judges
210    /// each session against the host as it was when that session was staged.
211    pub fn probe() -> HostProbe {
212        PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
213        #[cfg(target_os = "linux")]
214        let probe = HostProbe {
215            landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
216            sealed: crate::sealed::probe_sealed_support(),
217        };
218        #[cfg(not(target_os = "linux"))]
219        let probe = HostProbe {
220            landlock_fully_enforced: None,
221            sealed: crate::sealed::SealedProbe::default(),
222        };
223        probe
224    }
225
226    /// A probe value assembled from known halves, without touching the host. The seam the
227    /// fail-closed tests use to assert what an all-negative reading resolves to on any OS.
228    #[cfg(test)]
229    pub(crate) fn from_parts(
230        landlock_fully_enforced: Option<bool>,
231        sealed: crate::sealed::SealedProbe,
232    ) -> HostProbe {
233        HostProbe {
234            landlock_fully_enforced,
235            sealed,
236        }
237    }
238
239    /// The tier this reading supports. `pub(crate)` because [`EnforcementTier`] is.
240    pub(crate) fn tier(&self) -> EnforcementTier {
241        tier_from_probe(
242            cfg!(target_os = "linux"),
243            self.landlock_fully_enforced,
244            self.sealed,
245        )
246    }
247
248    /// Which single mechanism keeps this reading below `sealed`, or `None` when nothing does.
249    pub fn sealed_blocker(&self) -> Option<crate::sealed::SealedBlocker> {
250        crate::sealed::sealed_blocker(
251            cfg!(target_os = "linux"),
252            self.landlock_fully_enforced == Some(true),
253            self.sealed,
254        )
255    }
256
257    /// Where this host's permission to create an unprivileged user namespace comes from, or
258    /// `None` off Linux, where the question has no answer rather than a negative one.
259    pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
260        if cfg!(target_os = "linux") {
261            Some(self.sealed.userns_grant)
262        } else {
263            None
264        }
265    }
266
267    /// How many probes this process has taken, counting from zero at startup and never
268    /// decreasing.
269    ///
270    /// The observability hook the freshness tests assert against — that staging twice advances it
271    /// by two, and that one `stage_session` advances it by one — and nothing else reads it. No
272    /// runtime decision depends on the count.
273    pub fn probes_taken() -> u64 {
274        PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
275    }
276}
277
278/// The tier this host can back, from a reading taken here.
279///
280/// Answers a standalone host question — `mur doctor` and the escape-conformance harness ask it.
281/// A *session* must not: it reads its own [`HostProbe`] so its refusal, its trace and the
282/// mechanisms it installs describe one host at one moment.
283pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
284    HostProbe::probe().tier()
285}
286
287/// Which single mechanism keeps this host below `sealed`, or `None` when nothing does, from a
288/// reading taken here.
289///
290/// Takes its own probe, as its neighbours do. What a session needs — that the refusal an operator
291/// sees and the tier the runtime installs cannot disagree about the host — is upheld by
292/// `runtime::stage_session` passing one [`HostProbe`] to both, not by this function.
293pub fn detect_sealed_blocker() -> Option<crate::sealed::SealedBlocker> {
294    HostProbe::probe().sealed_blocker()
295}
296
297/// Where this host's unprivileged-user-namespace permission comes from, or `None` off Linux, where
298/// the question has no answer rather than a negative one.
299///
300/// Takes its own probe, as its neighbours do; a session reads the provenance off its own
301/// [`HostProbe`] instead, so what it is shown and the tier it installs describe one host at one
302/// moment.
303pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
304    HostProbe::probe().userns_grant()
305}
306
307/// Resolves every host in `network_allow` (via `crate::network_policy::parse_network_allow_rules`,
308/// the same parser already used for the WASI-HTTP-facing allowlist) to concrete IP addresses
309/// via DNS, once. Returns the deduplicated union of all resolved addresses across all allowed
310/// hosts.
311///
312/// Hosts that fail to resolve are silently skipped, matching `resolve_exec_allowlist`'s precedent
313/// below: the resolved set feeds *only* the kernel-level (Landlock/seccomp) allow set for
314/// `shell.allow` subprocesses, so a host contributing zero IPs merely *shrinks* that set (deny
315/// direction, fail-closed). A DNS miss must not be fatal — resolution happens unconditionally at
316/// staging time, before any WASM loads and regardless of whether the capsule declares
317/// `shell.allow` at all, so hard-failing here turned any transient resolver outage or
318/// non-resolving allowlist host into an E-IO-003 that blocked the whole run.
319///
320/// This does not widen what the capsule may reach: the WASM component's own outbound WASI-HTTP
321/// calls are gated separately by `network_policy::NetworkAllowRule::matches`, a host-pattern
322/// match that never consults DNS. Malformed host *syntax* is still a hard error — it is caught by
323/// `parse_network_allow_rules` above, before this loop.
324pub(crate) fn resolve_network_allowlist_ips(
325    network_allow: &[String],
326) -> Result<Vec<IpAddr>, String> {
327    let rules = crate::network_policy::parse_network_allow_rules(network_allow)
328        .map_err(|error| error.to_string())?;
329
330    let mut ips = std::collections::BTreeSet::new();
331    for rule in &rules {
332        let host = rule.host.as_str();
333        match crate::dns_resolver::resolve(host) {
334            crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
335            crate::dns_resolver::Resolution::DoesNotExist => {}
336            // Skipped like any other host that contributed no address, but said out loud: a
337            // resolver that did not answer is a transient condition an operator can act on,
338            // where a name that does not exist is a manifest to correct.
339            crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
340                "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
341                 resolved at launch: {reason} (a subprocess reaching it by literal address is \
342                 denied for this run)"
343            ),
344        }
345    }
346
347    Ok(ips.into_iter().collect())
348}
349
350/// Resolves each `capabilities.shell.allow` entry to the canonical filesystem path(s) of the
351/// binary it names, once, at launch time. Entries containing a path separator are
352/// canonicalized directly; bare names (the common case, per the manifest schema's
353/// "bare binary name" rule) are looked up in the host `PATH` the way `execvp` would, then
354/// canonicalized (resolving symlinks, `..`, etc.).
355///
356/// Entries that resolve to nothing (binary not installed) are silently skipped — that only
357/// *shrinks* the kernel-level allow set (deny direction, fail-closed) and matches reality:
358/// an exec of a binary that does not exist at launch fails anyway, and a binary *created
359/// after launch* under an allowlisted name (the attack this exists to stop) must not become
360/// allowlisted by virtue of its name.
361///
362/// This canonical set — not the raw name strings — is what `resolve_landlock_grants` derives the
363/// narrow read+execute Landlock grants from, so `cp /usr/bin/nc ./bash && ./bash` is denied: the
364/// copy lives under the workdir, which carries no `Execute` right by default, and the grant that
365/// does carry one names the launch-time `bash`'s own inode, not a basename.
366pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
367    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
368        .map(|path| std::env::split_paths(&path).collect())
369        .unwrap_or_default();
370    resolve_exec_allowlist_in(shell_allow, &path_dirs)
371}
372
373/// Testable core of `resolve_exec_allowlist`, with the `PATH` directory list injected. For a
374/// bare name, every executable `PATH` match is included (not just the first): all of them are
375/// binaries the operator's launch-time `PATH` exposes under that allowlisted name, and on
376/// merged-`/usr` distros `/bin/x` and `/usr/bin/x` canonicalize to the same entry anyway.
377fn resolve_exec_allowlist_in(shell_allow: &[String], path_dirs: &[PathBuf]) -> Vec<PathBuf> {
378    let mut resolved = std::collections::BTreeSet::new();
379    for entry in shell_allow {
380        if entry.is_empty() {
381            continue;
382        }
383        if entry.contains('/') {
384            if let Ok(canonical) = std::fs::canonicalize(entry) {
385                resolved.insert(canonical);
386            }
387            continue;
388        }
389        for dir in path_dirs {
390            let candidate = dir.join(entry);
391            if is_executable_file(&candidate) {
392                if let Ok(canonical) = std::fs::canonicalize(&candidate) {
393                    resolved.insert(canonical);
394                }
395            }
396        }
397    }
398    resolved.into_iter().collect()
399}
400
401/// Resolves the program name a shell tool invoked to the canonical filesystem path of the
402/// binary that `execvp` would actually run, for reporting purposes: `shell-event.binary`
403/// on the hook side and the `binary` key of `trace.jsonl`'s `shell` record.
404///
405/// Unlike [`resolve_exec_allowlist`] — which collects *every* `PATH` match because each one needs
406/// its own Landlock grant — this returns the **first** match, which is the one `execvp` picks and
407/// therefore the one that ran. Resolution rules are otherwise identical (same `PATH` source, same
408/// [`is_executable_file`] test, same `canonicalize`), so a reported path is always a member of the
409/// set the Landlock grants were derived from.
410///
411/// A name that resolves to nothing falls back to the bare invoked name, unchanged — never
412/// an error, never a panic, matching `resolve_exec_allowlist`'s "entries that resolve to
413/// nothing are silently skipped" precedent. Observability must not be able to fail a shell
414/// call that the OS is perfectly willing to run.
415pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
416    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
417        .map(|path| std::env::split_paths(&path).collect())
418        .unwrap_or_default();
419    resolve_invoked_binary_path_in(binary, &path_dirs)
420}
421
422/// Testable core of [`resolve_invoked_binary_path`], with the `PATH` directory list
423/// injected. `pub(crate)` because [`crate::reachability`] needs the same first-match resolution
424/// against an *injected* directory list — its own unit tests point `PATH` at a temp directory of
425/// synthetic script fixtures rather than at whatever the machine running the suite has installed.
426pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
427    let fallback = || binary.to_string();
428    if binary.is_empty() {
429        return fallback();
430    }
431    if binary.contains('/') {
432        return match std::fs::canonicalize(binary) {
433            Ok(canonical) => canonical.to_string_lossy().into_owned(),
434            Err(_) => fallback(),
435        };
436    }
437    for dir in path_dirs {
438        let candidate = dir.join(binary);
439        if is_executable_file(&candidate) {
440            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
441                return canonical.to_string_lossy().into_owned();
442            }
443        }
444    }
445    fallback()
446}
447
448/// The first executable named `binary` on this process's `PATH`, or `None` when there is none.
449///
450/// First match wins, which is the one `execvp` would run — the same rule
451/// [`resolve_invoked_binary_path_in`] reports under, so a caller naming a binary and a caller
452/// reporting one cannot disagree about which file `PATH` means. The path is returned as found,
453/// not canonicalized: callers here name a binary to a person rather than derive a grant from it.
454pub fn find_on_path(binary: &str) -> Option<PathBuf> {
455    std::env::split_paths(&std::env::var_os("PATH")?)
456        .map(|dir| dir.join(binary))
457        .find(|candidate| is_executable_file(candidate))
458}
459
460pub(crate) fn is_executable_file(path: &Path) -> bool {
461    use std::os::unix::fs::PermissionsExt;
462    std::fs::metadata(path)
463        .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
464        .unwrap_or(false)
465}
466
467// ---- derived Landlock read+execute grant set ----
468//
469// On `KernelFull`, Landlock scopes the shell subprocess tree to the workdir. The workdir rule
470// grants `linux_enforce::WORKDIR_ACCESS_RIGHTS` (everything in ABI v1 except device-node and
471// unix-socket creation), but — once `restrict_self()` lands — `Execute`/`ReadFile` are then denied
472// on every path *outside* the workdir, including the `shell.allow` binaries themselves, their ELF
473// interpreter (dynamic loader), and every shared library they pull in. That silently defeats the
474// seccomp exec-allowlist: each allowlisted `execve` fails with EACCES before it runs.
475//
476// The functions below derive the exact extra paths that must get a narrow read+execute grant
477// outside the workdir: the canonical `shell.allow` binaries (already resolved by
478// `resolve_exec_allowlist`), each one's `PT_INTERP`, and the transitive closure of the shared
479// libraries their ELF `DT_NEEDED` entries name (resolved via `DT_RPATH`/`DT_RUNPATH` first, then a
480// fixed set of standard library directories). Nothing broader — no directory is granted wholesale.
481//
482// All of this is *parsing and filesystem resolution*, so it runs in the parent at launch time
483// (inside `ShellEnforcement::resolve`) and the resulting `Vec<PathBuf>` is threaded into the
484// forked child's `pre_exec`, which only opens each path and adds a Landlock rule (no parsing in
485// the async-signal-safe window). Every resolution step is shrink-not-fail, matching
486// `resolve_exec_allowlist`: an entry that does not exist, does not parse as ELF, or names a
487// soname that does not resolve simply contributes nothing further — it never errors out.
488
489/// Standard Linux shared-library search directories used as the fallback resolution set for a
490/// `DT_NEEDED` soname when the binary declares no `DT_RPATH`/`DT_RUNPATH` (or it doesn't match).
491/// Covers both supported platform targets (`linux/x86_64` and `linux/aarch64`), including the
492/// Debian/Ubuntu multiarch-triplet subdirectories. Extend here for a platform this list does not
493/// cover — it is the single source of truth.
494const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
495    "/lib",
496    "/lib64",
497    "/usr/lib",
498    "/usr/lib64",
499    "/lib/x86_64-linux-gnu",
500    "/usr/lib/x86_64-linux-gnu",
501    "/lib/aarch64-linux-gnu",
502    "/usr/lib/aarch64-linux-gnu",
503];
504
505/// The subset of an ELF binary's dynamic-linking metadata that decides which extra files the
506/// dynamic loader will touch at load time: the interpreter path (`PT_INTERP`), the direct
507/// shared-library dependencies (`DT_NEEDED` sonames), and the runtime library search paths
508/// (`DT_RPATH`/`DT_RUNPATH`, already split on `:`, `$ORIGIN` left unexpanded here).
509#[derive(Debug, Default, PartialEq, Eq)]
510pub(crate) struct ElfDependencies {
511    pub(crate) interp: Option<String>,
512    pub(crate) needed: Vec<String>,
513    pub(crate) runpaths: Vec<String>,
514}
515
516fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
517    let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
518    Some(if le {
519        u16::from_le_bytes(raw)
520    } else {
521        u16::from_be_bytes(raw)
522    })
523}
524
525fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
526    let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
527    Some(if le {
528        u32::from_le_bytes(raw)
529    } else {
530        u32::from_be_bytes(raw)
531    })
532}
533
534fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
535    let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
536    Some(if le {
537        u64::from_le_bytes(raw)
538    } else {
539        u64::from_be_bytes(raw)
540    })
541}
542
543/// Reads a NUL-terminated string starting at the beginning of `region` (used for both the
544/// `PT_INTERP` segment and dynamic-string-table entries).
545fn cstr_at_start(region: &[u8]) -> String {
546    let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
547    String::from_utf8_lossy(&region[..nul]).into_owned()
548}
549
550/// Maps a virtual address into a file offset using the ELF's `PT_LOAD` segments — required
551/// because `DT_STRTAB` records the string table's virtual address, not its file offset.
552fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
553    for &(seg_vaddr, seg_off, seg_filesz) in loads {
554        if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
555            return Some(seg_off + (vaddr - seg_vaddr));
556        }
557    }
558    None
559}
560
561/// Pure ELF64 parser: extracts the dynamic-linking metadata that determines which extra files the
562/// loader touches. Returns `None` for anything that is not a well-formed ELF64 image (bad magic,
563/// 32-bit, truncated headers) — a non-ELF or unparseable binary simply contributes nothing to the
564/// grant set (shrink-not-fail). Deliberately hand-rolled (no crate dependency) and byte-slice-in /
565/// struct-out so it is fully unit-testable off-Linux with synthetic fixtures; it makes no syscalls.
566///
567/// Only ELF64 is parsed: both supported Linux targets (`x86_64`, `aarch64`) are 64-bit, so a
568/// 32-bit image is not a binary this runtime would exec and is treated as unparseable.
569pub(crate) fn parse_elf_dependencies(bytes: &[u8]) -> Option<ElfDependencies> {
570    // e_ident (16) + fixed ELF64 header fields extend to offset 64.
571    if bytes.len() < 64 || &bytes[0..4] != b"\x7fELF" {
572        return None;
573    }
574    // EI_CLASS: 2 == ELFCLASS64. EI_DATA: 1 == little-endian, 2 == big-endian.
575    if bytes[4] != 2 {
576        return None;
577    }
578    let le = match bytes[5] {
579        1 => true,
580        2 => false,
581        _ => return None,
582    };
583
584    let e_phoff = read_u64(bytes, 32, le)? as usize;
585    let e_phentsize = read_u16(bytes, 54, le)? as usize;
586    let e_phnum = read_u16(bytes, 56, le)? as usize;
587    // Each Elf64_Phdr is 56 bytes; a smaller entsize is not this layout.
588    if e_phentsize < 56 {
589        return None;
590    }
591
592    let mut interp: Option<String> = None;
593    let mut dynamic_region: Option<(u64, u64)> = None; // (file offset, size)
594    let mut loads: Vec<(u64, u64, u64)> = Vec::new(); // (p_vaddr, p_offset, p_filesz)
595
596    for i in 0..e_phnum {
597        let base = e_phoff.checked_add(i.checked_mul(e_phentsize)?)?;
598        let p_type = read_u32(bytes, base, le)?;
599        let p_offset = read_u64(bytes, base + 8, le)?;
600        let p_vaddr = read_u64(bytes, base + 16, le)?;
601        let p_filesz = read_u64(bytes, base + 32, le)?;
602        match p_type {
603            1 => loads.push((p_vaddr, p_offset, p_filesz)), // PT_LOAD
604            2 => dynamic_region = Some((p_offset, p_filesz)), // PT_DYNAMIC
605            3 => {
606                // PT_INTERP: a NUL-terminated interpreter path in the file image.
607                let start = p_offset as usize;
608                let end = start.checked_add(p_filesz as usize)?;
609                interp = Some(cstr_at_start(bytes.get(start..end)?));
610            }
611            _ => {}
612        }
613    }
614
615    // A static binary (no PT_DYNAMIC) contributes only itself — no interp, no needed libs.
616    let Some((dyn_off, dyn_size)) = dynamic_region else {
617        return Some(ElfDependencies {
618            interp,
619            needed: Vec::new(),
620            runpaths: Vec::new(),
621        });
622    };
623
624    let mut needed_offsets: Vec<u64> = Vec::new();
625    let mut runpath_offsets: Vec<u64> = Vec::new();
626    let mut strtab_vaddr: Option<u64> = None;
627    let mut strsz: Option<u64> = None;
628
629    let mut off = dyn_off as usize;
630    let dyn_end = off.checked_add(dyn_size as usize)?;
631    while off + 16 <= dyn_end {
632        let d_tag = read_u64(bytes, off, le)?;
633        let d_val = read_u64(bytes, off + 8, le)?;
634        match d_tag {
635            0 => break,                             // DT_NULL — end of dynamic array
636            1 => needed_offsets.push(d_val),        // DT_NEEDED
637            5 => strtab_vaddr = Some(d_val),        // DT_STRTAB (virtual address)
638            10 => strsz = Some(d_val),              // DT_STRSZ
639            15 | 29 => runpath_offsets.push(d_val), // DT_RPATH | DT_RUNPATH
640            _ => {}
641        }
642        off += 16;
643    }
644
645    let mut needed = Vec::new();
646    let mut runpaths = Vec::new();
647    if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
648        if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
649            let start = strtab_off as usize;
650            // Prefer the declared string-table bounds, but tolerate an over-long DT_STRSZ by
651            // falling back to the rest of the file rather than dropping every soname.
652            let strtab = start
653                .checked_add(strsz as usize)
654                .and_then(|end| bytes.get(start..end))
655                .or_else(|| bytes.get(start..));
656            if let Some(strtab) = strtab {
657                for offset in needed_offsets {
658                    if let Some(region) = strtab.get(offset as usize..) {
659                        needed.push(cstr_at_start(region));
660                    }
661                }
662                for offset in runpath_offsets {
663                    if let Some(region) = strtab.get(offset as usize..) {
664                        for part in cstr_at_start(region).split(':') {
665                            if !part.is_empty() {
666                                runpaths.push(part.to_string());
667                            }
668                        }
669                    }
670                }
671            }
672        }
673    }
674
675    Some(ElfDependencies {
676        interp,
677        needed,
678        runpaths,
679    })
680}
681
682/// Expands a leading/embedded `$ORIGIN` (or `${ORIGIN}`) in an rpath entry to the directory of the
683/// binary that declared it — the dynamic loader's own rule for `DT_RUNPATH`/`DT_RPATH`.
684fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
685    if rpath.contains("$ORIGIN") {
686        let origin = origin_dir.to_string_lossy();
687        PathBuf::from(
688            rpath
689                .replace("${ORIGIN}", &origin)
690                .replace("$ORIGIN", &origin),
691        )
692    } else {
693        PathBuf::from(rpath)
694    }
695}
696
697/// Resolves one `DT_NEEDED` soname to a canonical file path, mirroring the loader's search order:
698/// a soname containing a `/` is taken as a path (absolute, or relative to the binary's directory);
699/// otherwise the binary's own `DT_RUNPATH`/`DT_RPATH` entries (with `$ORIGIN` expanded) are tried
700/// first, then the standard `search_dirs`. Returns `None` (shrink-not-fail) if nothing matches.
701pub(crate) fn resolve_soname(
702    soname: &str,
703    origin_dir: &Path,
704    runpaths: &[String],
705    search_dirs: &[PathBuf],
706) -> Option<PathBuf> {
707    if soname.is_empty() {
708        return None;
709    }
710    if soname.contains('/') {
711        let candidate = if Path::new(soname).is_absolute() {
712            PathBuf::from(soname)
713        } else {
714            origin_dir.join(soname)
715        };
716        return std::fs::canonicalize(&candidate).ok();
717    }
718    for rpath in runpaths {
719        let candidate = expand_origin(rpath, origin_dir).join(soname);
720        if candidate.is_file() {
721            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
722                return Some(canonical);
723            }
724        }
725    }
726    for dir in search_dirs {
727        let candidate = dir.join(soname);
728        if candidate.is_file() {
729            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
730                return Some(canonical);
731            }
732        }
733    }
734    None
735}
736
737/// Derives the full set of canonical file paths that need a narrow read+execute Landlock grant
738/// *outside* the workdir so the `shell.allow` binaries can actually run: each allowlisted binary,
739/// its ELF interpreter, and the transitive closure of its `DT_NEEDED` shared libraries. Uses the
740/// standard [`DEFAULT_LIBRARY_SEARCH_DIRS`] as the soname fallback search set.
741///
742/// Every step is shrink-not-fail (a binary that can't be read/parsed, or a soname that doesn't
743/// resolve, contributes nothing further); the returned set is deduplicated and canonical. Runs in
744/// the parent at launch time — never in the forked child's `pre_exec`.
745pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
746    let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
747        .iter()
748        .map(PathBuf::from)
749        .collect();
750    resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
751}
752
753/// Testable core of [`resolve_landlock_grants`], with the library search directories injected so
754/// unit tests can point it at synthetic fixtures instead of the real system directories.
755fn resolve_landlock_grants_in(
756    exec_allow_paths: &[PathBuf],
757    search_dirs: &[PathBuf],
758) -> Vec<PathBuf> {
759    let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
760    let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
761    let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
762
763    for path in exec_allow_paths {
764        // The allowlist paths are already canonical/existing (from `resolve_exec_allowlist`), but
765        // canonicalize defensively — this both makes dedup against interp/library paths exact and
766        // drops any seed that no longer exists (shrink-not-fail: a vanished binary contributes
767        // nothing, and could not be exec'd anyway).
768        if let Ok(canonical) = std::fs::canonicalize(path) {
769            queue.push_back(canonical);
770        }
771    }
772
773    while let Some(path) = queue.pop_front() {
774        if !visited.insert(path.clone()) {
775            continue;
776        }
777        // The binary/library itself is granted. If it lives inside the workdir, the workdir's own
778        // full-access rule already covers it and this extra read+execute rule is simply redundant.
779        grants.insert(path.clone());
780
781        let Ok(bytes) = std::fs::read(&path) else {
782            continue;
783        };
784        let Some(deps) = parse_elf_dependencies(&bytes) else {
785            continue;
786        };
787        let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
788
789        if let Some(interp) = &deps.interp {
790            if let Ok(canonical) = std::fs::canonicalize(interp) {
791                queue.push_back(canonical);
792            }
793        }
794        for soname in &deps.needed {
795            if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
796            {
797                queue.push_back(resolved);
798            }
799        }
800    }
801
802    grants.into_iter().collect()
803}
804
805/// One resolved Landlock filesystem grant outside the workdir: a canonical path plus the two
806/// independent bits that decide the rule's access set.
807///
808/// `list_dir` decides enumerability (`ReadDir`):
809///
810///   - `false` → no `getdents64` on the granted directory itself. Landlock's read rights apply to
811///     the whole subtree beneath a granted directory, so a file *inside* it can still be opened by
812///     its exact name — the listing is what is denied. This is what every derived
813///     `DT_NEEDED`-closure grant gets — they are individual files, where `ReadDir` was always a
814///     no-op anyway (Landlock's `ReadDir` only has meaning on a directory inode).
815///   - `true` → adds enumerability, which a path-based interpreter's import machinery needs on
816///     each `sys.path` entry (CPython's `FileFinder` `listdir`-caches each one). Set by an author
817///     writing `list_dir: true` next to a specific `interpreter_runtime` directory, and by the
818///     whole-tree resolvers ([`resolve_staged_runtime_landlock_grants`],
819///     [`resolve_sealed_runtime_landlock_grants`] and, per entry, the directory half of
820///     [`resolve_sealed_etc_landlock_grants`]) where a tree is walked by definition — never
821///     inferred from anything else, and never applied to an ancestor or sibling of a granted
822///     directory.
823///
824/// `ReadDir` is meaningful only on a directory, and the kernel enforces that: `landlock_add_rule`
825/// returns `EINVAL` for a directory-only right on a non-directory. `open_landlock_fds` therefore
826/// narrows `list_dir` to `false` for any grant whose fd does not `fstat` as a directory, so this
827/// field is a request rather than a promise.
828///
829/// `executable` decides `Execute`, which is this runtime's exec allowlist: a path without an
830/// `Execute` rule cannot be `execve`d, and a path with one can. This is the canonical statement of
831/// that rule; the grant resolvers below cross-reference it rather than restating it.
832///
833///   - `true` → `Execute + ReadFile`, for a path the manifest asked to *run*: a `shell.allow`
834///     binary, its `DT_NEEDED` closure, and the interpreter/staged trees an author named.
835///   - `false` → `ReadFile` only (readable, and with `list_dir` enumerable, but not runnable).
836///     For a grant the *tier* issues rather than the manifest: see
837///     [`resolve_sealed_runtime_landlock_grants`], where granting `Execute` over `/usr`, `/bin`
838///     and `/sbin` wholesale would turn `shell.allow` into a no-op on `sealed`, and
839///     [`resolve_sealed_etc_landlock_grants`], where `/etc/alternatives` is a directory of
840///     symlinks into exactly those trees.
841///
842/// Affects `execve` only: a shared object mapped `PROT_EXEC` by `dlopen(3)` needs `ReadFile`, not
843/// `Execute`, so an interpreter still loads its C extensions out of an `executable: false` tree.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub(crate) struct LandlockGrant {
846    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
847    pub(crate) path: PathBuf,
848    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
849    pub(crate) list_dir: bool,
850    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
851    pub(crate) executable: bool,
852}
853
854impl LandlockGrant {
855    /// Wraps each derived `DT_NEEDED`-closure file path as a non-listable grant. A regular file
856    /// has no meaningful `ReadDir`, so `list_dir: false` costs nothing. `executable: true`: this
857    /// closure is exactly the set of paths `shell.allow` asked to run — see [`LandlockGrant`].
858    fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
859        paths
860            .into_iter()
861            .map(|path| LandlockGrant {
862                path,
863                list_dir: false,
864                executable: true,
865            })
866            .collect()
867    }
868}
869
870/// Turns each `capabilities.shell.interpreter_runtime` directory into a [`LandlockGrant`] carrying
871/// exactly the `list_dir` its author wrote. Pure and syscall-free (the declared paths need not
872/// exist here — `apply_landlock_scope` skips any that fail to open), so it is unit-testable on
873/// every platform, including non-Linux targets.
874///
875/// There is deliberately no path here that could widen a grant to a whole install prefix: it emits
876/// one grant per explicitly declared directory and copies the author's `list_dir` verbatim,
877/// inferring enumerability from nothing.
878pub(crate) fn resolve_interpreter_runtime_grants(
879    grants: &[InterpreterRuntimeGrant],
880) -> Vec<LandlockGrant> {
881    grants
882        .iter()
883        .flat_map(|grant| {
884            grant.dirs.iter().map(|dir| LandlockGrant {
885                path: PathBuf::from(&dir.path),
886                list_dir: dir.list_dir,
887                // Executable: an `interpreter_runtime` directory is named by an author precisely so
888                // the interpreter it belongs to can run out of it, helper binaries included.
889                executable: true,
890            })
891        })
892        .collect()
893}
894
895/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
896/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
897/// allowlisted binary, and each declared `interpreter_runtime` directory.
898///
899/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
900/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
901/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
902/// derivation `scoped` needs has nothing left to do here.
903///
904/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
905/// makes the composed root narrower rather than refusing the launch, which is why
906/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
907/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
908/// required bind. This function is additive to that, not replaced by it.
909///
910/// Pure and syscall-free, so it is unit-testable on any platform.
911pub(crate) fn resolve_sealed_bind_dirs(
912    exec_allow_paths: &[PathBuf],
913    policy: &CapabilityPolicy,
914) -> Vec<PathBuf> {
915    let fixed: Vec<&Path> = crate::sealed::SEALED_RUNTIME_PATHS
916        .iter()
917        .map(Path::new)
918        .collect();
919
920    let mut dirs: Vec<PathBuf> = Vec::new();
921    let candidates = exec_allow_paths
922        .iter()
923        .filter_map(|binary| binary.parent().map(Path::to_path_buf))
924        .chain(
925            policy
926                .shell_interpreter_runtime
927                .iter()
928                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
929        );
930
931    for candidate in candidates {
932        if !candidate.is_absolute() {
933            continue;
934        }
935        if fixed.iter().any(|root| candidate.starts_with(root)) {
936            continue;
937        }
938        if dirs.contains(&candidate) {
939            continue;
940        }
941        dirs.push(candidate);
942    }
943    dirs
944}
945
946/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
947/// root's *required* read-only binds.
948///
949/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
950/// way that function filters. Both differences follow from the same fact — these fail closed:
951///
952///   * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
953///     Dropping it would be sound for an optional bind (the fixed list already covers it) but it
954///     would quietly convert a required grant into a dependency on an unrelated list staying the
955///     way it is today. `plan_composed_root` runs the required loop first and deduplicates by
956///     target, so naming an already-covered path costs one registration and stays required.
957///   * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
958///     grant naming a tree this host does not have refuses the launch — see
959///     `sealed::PlanBuilder::require_bind`.
960///
961/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
962/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
963/// layer that rejects one.
964///
965/// Pure and syscall-free, so it is unit-testable on any platform.
966pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
967    let mut dirs: Vec<PathBuf> = Vec::new();
968    for grant in &policy.shell_staged_runtime {
969        let path = PathBuf::from(&grant.source_path);
970        if !path.is_absolute() || dirs.contains(&path) {
971            continue;
972        }
973        dirs.push(path);
974    }
975    dirs
976}
977
978/// One listable [`LandlockGrant`] per staged runtime directory.
979///
980/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
981/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
982/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
983/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
984/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
985/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
986/// working at all.
987///
988/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
989/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
990/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
991/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
992/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
993/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
994/// same inode reached through the bind inside the root.
995///
996/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
997/// of two independent reasons the tree cannot be mutated from inside a session.
998///
999/// Pure and syscall-free, so it is unit-testable on any platform.
1000pub(crate) fn resolve_staged_runtime_landlock_grants(
1001    policy: &CapabilityPolicy,
1002) -> Vec<LandlockGrant> {
1003    resolve_staged_runtime_dirs(policy)
1004        .into_iter()
1005        .map(|path| LandlockGrant {
1006            path,
1007            list_dir: true,
1008            executable: true,
1009        })
1010        .collect()
1011}
1012
1013/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1014/// `KernelSealed`.
1015///
1016/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1017/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1018/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1019/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1020/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1021/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1022/// enumerable.
1023///
1024/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1025/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1026///
1027/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1028/// the manifest never named, so it must enumerate them without becoming permission to run them.
1029///
1030/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1031/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1032/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1033/// nothing. Empty on every other tier.
1034///
1035/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1036/// to open), so unit-testable on every platform.
1037pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1038    if tier != EnforcementTier::KernelSealed {
1039        return Vec::new();
1040    }
1041    crate::sealed::SEALED_RUNTIME_PATHS
1042        .iter()
1043        .map(|path| LandlockGrant {
1044            path: PathBuf::from(path),
1045            list_dir: true,
1046            executable: false,
1047        })
1048        .collect()
1049}
1050
1051/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1052/// `KernelSealed`.
1053///
1054/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1055/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1056/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1057/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1058/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1059/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1060/// fail certificate verification over an otherwise-working TCP connection.
1061///
1062/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1063/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1064/// directories, regular files and symlinks.
1065/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1066/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1067/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1068///
1069/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1070/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1071/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1072/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1073/// prevent.
1074///
1075/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1076/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1077///
1078/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1079/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1080/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1081/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1082/// reading it reveals nothing sensitive. Empty on every other tier.
1083///
1084/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1085/// open), so unit-testable on every platform.
1086pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1087    if tier != EnforcementTier::KernelSealed {
1088        return Vec::new();
1089    }
1090    crate::sealed::SEALED_ETC_PATHS
1091        .iter()
1092        .map(|entry| LandlockGrant {
1093            path: PathBuf::from(entry.path),
1094            list_dir: entry.directory,
1095            executable: false,
1096        })
1097        .collect()
1098}
1099
1100// ---- fixed capsule device set ------------------------------------------------------------
1101//
1102// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1103// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1104// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1105// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1106// device, and none that takes one away.
1107//
1108// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1109// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1110// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1111// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1112
1113/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1114///
1115/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1116/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1117/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1118/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1119/// `list_dir` into a third meaning.
1120///
1121/// `writable` is the whole reason this type exists as more than a path list:
1122///
1123///   - `false` → `ReadFile` only. The device is readable and nothing more.
1124///   - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, and it is the
1125///     only writable path outside the workdir in the entire sandbox.
1126///
1127/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1128/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1129/// module's Linux-only surface carries.
1130#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132pub(crate) struct CapsuleDeviceGrant {
1133    pub(crate) path: &'static str,
1134    pub(crate) writable: bool,
1135}
1136
1137/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1138/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1139/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1140/// stays denied by the same mechanism that already denies every other unlisted path: with
1141/// `handle_access` declaring the full ABI v1 right-set, "no rule" means "denied", not "ungoverned".
1142///
1143///   - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1144///     outside the workdir is writable". A read-only grant would be broken, not merely strict:
1145///     bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1146///     shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1147///     and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1148///     write on a character device whose write side is defined to discard everything gives up no
1149///     confidentiality and no integrity — there is no state behind it to reach.
1150///     (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1151///     file is actually created, and `/dev/null` already exists.)
1152///   - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1153///     in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1154///     write there, so it does not get `WriteFile`.
1155///   - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1156///     filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1157///     device outright and fall over if they cannot.
1158///
1159/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1160/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1161/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1162/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1163/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1164/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1165///
1166/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1167/// workload has been observed failing on the missing device, and record that failure in the same
1168/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1169///
1170/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1171/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1172/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1173/// implementation should *retire* this constant, not merge with it.
1174#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1175pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1176    CapsuleDeviceGrant {
1177        path: "/dev/null",
1178        writable: true,
1179    },
1180    CapsuleDeviceGrant {
1181        path: "/dev/zero",
1182        writable: false,
1183    },
1184    CapsuleDeviceGrant {
1185        path: "/dev/urandom",
1186        writable: false,
1187    },
1188];
1189
1190/// Network decision for one destination IP.
1191/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1192/// no reason to open any subprocess socket).
1193#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1194pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1195    network_allow_ips.contains(&ip)
1196}
1197
1198// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1199// rather than `libc::AF_*`: the filter these feed is
1200// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1201// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1202// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1203// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1204// of the three from `libc` and two from literals would be worse than taking all three the same
1205// way. Values are stable kernel ABI: `include/linux/socket.h`.
1206const LINUX_AF_UNIX: i32 = 1;
1207const LINUX_AF_INET: i32 = 2;
1208const LINUX_AF_INET6: i32 = 10;
1209const LINUX_AF_NETLINK: i32 = 16;
1210const LINUX_AF_PACKET: i32 = 17;
1211
1212/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1213/// `capabilities.network.unix_sockets` declaration.
1214///
1215/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1216/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1217/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1218///
1219/// Why a domain check is a classic BPF rule and not a notify:
1220/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1221/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1222/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1223/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1224/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1225/// pointed-to argument out of another task's memory invites.
1226///
1227/// The three families, and why they are not symmetric:
1228///   - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1229///     by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1230///     need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1231///     Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1232///   - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1233///     capture and injection) are denied unconditionally, with no manifest key to widen them.
1234///     Neither has a shell-tool use case comparable to a local daemon socket, and both are
1235///     strictly more dangerous than one. Widening them would be a deliberate future decision with
1236///     its own justification, not an oversight to be fixed by adding a flag here.
1237///
1238/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1239/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1240/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1241/// get a positive rule — since the filter's default action is a deny, a family named by neither
1242/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1243#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1244pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1245    let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1246    if !unix_sockets_allowed {
1247        denied.push(LINUX_AF_UNIX);
1248    }
1249    denied
1250}
1251
1252/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1253/// `capabilities.network.unix_sockets` declaration. The exact complement of
1254/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1255///
1256/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1257/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1258/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1259/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1260/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1261/// equality-matched sets that a test can check against each other.
1262///
1263/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1264/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1265/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1266/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1267/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1268/// reason) if a real workload needs it.
1269#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1270pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1271    let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1272    if unix_sockets_allowed {
1273        allowed.push(LINUX_AF_UNIX);
1274    }
1275    allowed
1276}
1277
1278/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1279/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1280///
1281/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1282/// reason: the filter is always compiled for a Linux child.
1283#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1284const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1285#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1286const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1287
1288/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1289/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1290/// is the maintained source of the same list Docker ships).
1291///
1292/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1293/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1294///
1295///   - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1296///     [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1297///
1298/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1299/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1300/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1301/// child's very first act after this filter loads is the `execve` that turns it into the tool
1302/// binary, so a denial here refuses every capsule outright.
1303///
1304/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1305/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1306/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1307/// `io_uring` is the one that
1308/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1309/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1310/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1311///
1312/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1313/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1314/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1315/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1316/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1317/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1318#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1319pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1320    // ---- process / thread lifecycle ----
1321    // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1322    // doc comment for the mechanism that replaced their former `Notify` rules.
1323    "execve",
1324    "execveat",
1325    // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1326    // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1327    // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1328    // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1329    // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1330    // largely inert.
1331    "clone",
1332    "clone3",
1333    "fork",
1334    "vfork",
1335    "exit",
1336    "exit_group",
1337    "wait4",
1338    "waitid",
1339    "kill",
1340    "tkill",
1341    "tgkill",
1342    "rt_sigaction",
1343    "rt_sigprocmask",
1344    "rt_sigreturn",
1345    "rt_sigsuspend",
1346    "rt_sigpending",
1347    "rt_sigtimedwait",
1348    "rt_sigqueueinfo",
1349    "rt_tgsigqueueinfo",
1350    "sigaltstack",
1351    // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1352    // would break every `sleep`/`read` that takes a signal.
1353    "restart_syscall",
1354    "pause",
1355    "set_tid_address",
1356    "set_robust_list",
1357    "get_robust_list",
1358    // glibc 2.35+ registers a restartable sequence on every thread start.
1359    "rseq",
1360    "prctl",
1361    "arch_prctl",
1362    "getpid",
1363    "getppid",
1364    "gettid",
1365    "getpgrp",
1366    "getpgid",
1367    "setpgid",
1368    "getsid",
1369    "setsid",
1370    "getpriority",
1371    "setpriority",
1372    "ioprio_get",
1373    "ioprio_set",
1374    "sched_yield",
1375    "sched_getaffinity",
1376    "sched_setaffinity",
1377    "sched_getparam",
1378    "sched_setparam",
1379    "sched_getscheduler",
1380    "sched_setscheduler",
1381    "sched_getattr",
1382    "sched_setattr",
1383    "sched_get_priority_max",
1384    "sched_get_priority_min",
1385    "sched_rr_get_interval",
1386    "getcpu",
1387    "capget",
1388    "capset",
1389    "prlimit64",
1390    "getrlimit",
1391    "setrlimit",
1392    "getrusage",
1393    "getitimer",
1394    "setitimer",
1395    "times",
1396    "uname",
1397    "sysinfo",
1398    // pidfd process handles: they only ever address a process the caller could already signal.
1399    "pidfd_open",
1400    "pidfd_send_signal",
1401    // ---- memory ----
1402    "mmap",
1403    "munmap",
1404    "mprotect",
1405    "mremap",
1406    "madvise",
1407    "brk",
1408    "mlock",
1409    "mlock2",
1410    "munlock",
1411    "mlockall",
1412    "munlockall",
1413    "membarrier",
1414    "msync",
1415    "mincore",
1416    "memfd_create",
1417    "pkey_alloc",
1418    "pkey_free",
1419    "pkey_mprotect",
1420    "shmget",
1421    "shmat",
1422    "shmdt",
1423    "shmctl",
1424    // ---- file I/O ----
1425    "open",
1426    "openat",
1427    "openat2",
1428    "creat",
1429    "close",
1430    "close_range",
1431    "read",
1432    "write",
1433    "pread64",
1434    "pwrite64",
1435    "readv",
1436    "writev",
1437    "preadv",
1438    "pwritev",
1439    "preadv2",
1440    "pwritev2",
1441    "lseek",
1442    "dup",
1443    "dup2",
1444    "dup3",
1445    "fcntl",
1446    "flock",
1447    "fsync",
1448    "fdatasync",
1449    "sync",
1450    "syncfs",
1451    "sync_file_range",
1452    "fadvise64",
1453    "readahead",
1454    "truncate",
1455    "ftruncate",
1456    "fallocate",
1457    "stat",
1458    "fstat",
1459    "lstat",
1460    "newfstatat",
1461    "statx",
1462    "access",
1463    "faccessat",
1464    "faccessat2",
1465    "getdents",
1466    "getdents64",
1467    "getcwd",
1468    "chdir",
1469    "fchdir",
1470    "mkdir",
1471    "mkdirat",
1472    "rmdir",
1473    "unlink",
1474    "unlinkat",
1475    "rename",
1476    "renameat",
1477    "renameat2",
1478    "link",
1479    "linkat",
1480    "symlink",
1481    "symlinkat",
1482    "readlink",
1483    "readlinkat",
1484    "chmod",
1485    "fchmod",
1486    "fchmodat",
1487    // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
1488    // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
1489    "fchmodat2",
1490    "chown",
1491    "fchown",
1492    "fchownat",
1493    "lchown",
1494    "umask",
1495    "utime",
1496    "utimes",
1497    "utimensat",
1498    "futimesat",
1499    // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
1500    // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
1501    // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
1502    "mknod",
1503    "mknodat",
1504    "sendfile",
1505    "copy_file_range",
1506    "splice",
1507    "tee",
1508    "vmsplice",
1509    "ioctl",
1510    "poll",
1511    "ppoll",
1512    "select",
1513    "pselect6",
1514    "epoll_create",
1515    "epoll_create1",
1516    "epoll_ctl",
1517    "epoll_wait",
1518    "epoll_pwait",
1519    "epoll_pwait2",
1520    "eventfd",
1521    "eventfd2",
1522    "pipe",
1523    "pipe2",
1524    "inotify_init",
1525    "inotify_init1",
1526    "inotify_add_watch",
1527    "inotify_rm_watch",
1528    "signalfd",
1529    "signalfd4",
1530    "timerfd_create",
1531    "timerfd_settime",
1532    "timerfd_gettime",
1533    "getxattr",
1534    "lgetxattr",
1535    "fgetxattr",
1536    "setxattr",
1537    "lsetxattr",
1538    "fsetxattr",
1539    "listxattr",
1540    "llistxattr",
1541    "flistxattr",
1542    "removexattr",
1543    "lremovexattr",
1544    "fremovexattr",
1545    "statfs",
1546    "fstatfs",
1547    // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
1548    // into an open file, ignoring the path it was reached by) stays denied.
1549    "name_to_handle_at",
1550    // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
1551    // path resolution of its own, so it carries none of the LSM-bypass property that got the
1552    // `io_uring_*` family denied.
1553    "io_setup",
1554    "io_destroy",
1555    "io_submit",
1556    "io_cancel",
1557    "io_getevents",
1558    // ---- sockets ----
1559    // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
1560    //
1561    // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
1562    // capsule's own network namespace has no route to anything except the egress proxy. The
1563    // destination check lives there, not in this filter — see `crate::network_namespace`.
1564    "connect",
1565    "sendto",
1566    "socketpair",
1567    "bind",
1568    "listen",
1569    "accept",
1570    "accept4",
1571    "getsockname",
1572    "getpeername",
1573    "getsockopt",
1574    "setsockopt",
1575    "shutdown",
1576    "sendmsg",
1577    "sendmmsg",
1578    "recvfrom",
1579    "recvmsg",
1580    "recvmmsg",
1581    // ---- time ----
1582    "clock_gettime",
1583    "clock_getres",
1584    "clock_nanosleep",
1585    "nanosleep",
1586    "gettimeofday",
1587    "time",
1588    "timer_create",
1589    "timer_settime",
1590    "timer_gettime",
1591    "timer_getoverrun",
1592    "timer_delete",
1593    "alarm",
1594    // ---- System V / POSIX IPC ----
1595    "msgget",
1596    "msgsnd",
1597    "msgrcv",
1598    "msgctl",
1599    "semget",
1600    "semop",
1601    "semctl",
1602    "semtimedop",
1603    "mq_open",
1604    "mq_unlink",
1605    "mq_timedsend",
1606    "mq_timedreceive",
1607    "mq_notify",
1608    "mq_getsetattr",
1609    // ---- misc ----
1610    "getrandom",
1611    "futex",
1612    "futex_waitv",
1613    "getuid",
1614    "geteuid",
1615    "getgid",
1616    "getegid",
1617    "setuid",
1618    "setgid",
1619    "setreuid",
1620    "setregid",
1621    "setresuid",
1622    "setresgid",
1623    "getresuid",
1624    "getresgid",
1625    "getgroups",
1626    "setgroups",
1627    "setfsuid",
1628    "setfsgid",
1629];
1630
1631/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
1632/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
1633/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
1634/// allowlist while reconciling it against a newer upstream profile.
1635///
1636/// Three groups, and they are not equally negotiable:
1637///
1638///   1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
1639///      them: `io_uring_setup`/`io_uring_enter`/
1640///      `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
1641///      around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
1642///      `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
1643///      (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
1644///      `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
1645///      `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
1646///      deliberately stricter there.
1647///   2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
1648///      mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
1649///      clock setting, NUMA policy, `fanotify`.
1650///   3. **Denied instead of argument-conditioned.** Docker allows these only for specific
1651///      argument values, which a per-syscall-name allowlist cannot express and which would
1652///      otherwise have to be routed through a userspace decision this filter no longer makes.
1653///      Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
1654///      standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
1655///      itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
1656// The list is a specification, not an input to the filter build: its one consumer is the
1657// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
1658// the thing that test checks against.
1659#[allow(dead_code)]
1660pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
1661    // 1. demonstrated reachable on bare metal
1662    "io_uring_setup",
1663    "io_uring_enter",
1664    "io_uring_register",
1665    "bpf",
1666    "userfaultfd",
1667    "perf_event_open",
1668    "process_vm_readv",
1669    "process_vm_writev",
1670    "open_by_handle_at",
1671    "keyctl",
1672    "add_key",
1673    "request_key",
1674    "ptrace",
1675    // 2. privileged / host-state
1676    "mount",
1677    "umount2",
1678    "pivot_root",
1679    "chroot",
1680    "unshare",
1681    "setns",
1682    "init_module",
1683    "finit_module",
1684    "delete_module",
1685    "kexec_load",
1686    "kexec_file_load",
1687    "reboot",
1688    "swapon",
1689    "swapoff",
1690    "acct",
1691    "quotactl",
1692    "nfsservctl",
1693    "iopl",
1694    "ioperm",
1695    "vm86",
1696    "vm86old",
1697    "_sysctl",
1698    "sysfs",
1699    "uselib",
1700    "create_module",
1701    "get_kernel_syms",
1702    "query_module",
1703    "lookup_dcookie",
1704    "clock_adjtime",
1705    "clock_settime",
1706    "settimeofday",
1707    "stime",
1708    "adjtimex",
1709    "set_mempolicy",
1710    "get_mempolicy",
1711    "mbind",
1712    "migrate_pages",
1713    "move_pages",
1714    "fanotify_init",
1715    "fanotify_mark",
1716    // 3. denied instead of argument-conditioned
1717    "seccomp",
1718    "personality",
1719    "kcmp",
1720];
1721
1722/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
1723/// project treats the filter as having outgrown its headroom. Held against the *measured* export
1724/// of the real filter by a test, not against an estimate: growing
1725/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
1726/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
1727///
1728/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
1729/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
1730/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
1731/// number was chosen against.
1732#[allow(dead_code)]
1733pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
1734
1735/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
1736/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
1737/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
1738/// two filters that straddle it in forked children.
1739#[allow(dead_code)]
1740pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
1741
1742/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
1743/// attached to one task may total, counting every filter it already carries plus a small
1744/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
1745/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
1746/// quoted: a test stacks filters in a forked child until the refusal arrives.
1747#[allow(dead_code)]
1748pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
1749
1750/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
1751#[derive(Debug, Clone)]
1752pub(crate) struct ShellEnforcement {
1753    pub(crate) tier: EnforcementTier,
1754    /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
1755    /// by [`resolve_network_allowlist_ips`].
1756    ///
1757    /// The narrower of the egress proxy's two checks — the one applied to a destination the
1758    /// capsule reached without ever resolving a name for it (a hardcoded literal). See
1759    /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
1760    /// [`network_ip_allowed`].
1761    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1762    pub(crate) network_allow_ips: Vec<IpAddr>,
1763    /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
1764    /// checks a name when it resolves it and again when the connection to the address that name
1765    /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
1766    /// many tenants) in either direction.
1767    ///
1768    /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
1769    /// entry cannot mean one thing to a WASM guest and another to a subprocess.
1770    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1771    pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
1772    /// The TCP ports the capsule's network namespace opens listeners on, derived from the
1773    /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
1774    /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
1775    /// consulted — the strongest form the refusal can take.
1776    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1777    pub(crate) egress_tcp_ports: Vec<u16>,
1778    /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
1779    /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
1780    /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
1781    /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
1782    /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
1783    /// platform for parity but never read off Linux.
1784    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1785    pub(crate) unix_sockets_allowed: bool,
1786    /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
1787    /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
1788    /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
1789    /// writes into its workdir can run, under any name. Consumed only by
1790    /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
1791    /// for parity but never read off Linux.
1792    ///
1793    /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
1794    /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
1795    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1796    pub(crate) workdir_exec: bool,
1797    /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
1798    /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
1799    /// stdlib. Five origins, combined here:
1800    ///
1801    ///   - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
1802    ///     shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
1803    ///     (they are individual files, where `ReadDir` was always a no-op);
1804    ///   - one grant per `capabilities.shell.interpreter_runtime` directory, from
1805    ///     `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
1806    ///     declared;
1807    ///   - one listable grant per `capabilities.shell.staged_runtime` tree, from
1808    ///     `resolve_staged_runtime_landlock_grants`;
1809    ///   - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
1810    ///     [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
1811    ///     `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
1812    ///     tree, which nothing else grants;
1813    ///   - on `KernelSealed` **only**, one non-executable read grant per fixed
1814    ///     [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
1815    ///     (`list_dir` per entry, since that list mixes directories and files) — the composed
1816    ///     root's `/etc` allowlist, likewise granted by nothing else.
1817    ///
1818    /// The first three carry `executable: true`; the two tier-issued ones do not, and that
1819    /// asymmetry is load-bearing — see [`LandlockGrant`].
1820    ///
1821    /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
1822    /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
1823    /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
1824    /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
1825    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1826    pub(crate) landlock_grants: Vec<LandlockGrant>,
1827    /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
1828    /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
1829    /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
1830    ///
1831    /// Whole directories, never files: a bind carries a directory's whole contents, so this must
1832    /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
1833    /// every platform for parity, exactly like `landlock_grants` above.
1834    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1835    pub(crate) sealed_bind_dirs: Vec<PathBuf>,
1836    /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
1837    /// read-only into the composed root at its own absolute path.
1838    ///
1839    /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
1840    /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
1841    /// while a staged tree that cannot be mounted aborts the composed-root construction before
1842    /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
1843    /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
1844    /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
1845    /// parity, exactly like the two fields above.
1846    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1847    pub(crate) staged_runtime_dirs: Vec<PathBuf>,
1848    /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
1849    /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
1850    /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
1851    /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
1852    /// half. See [`crate::resources`].
1853    pub(crate) resource_limits: crate::resources::HostResourceLimits,
1854    /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
1855    /// for capsules that can actually spawn a native subprocess). `None` on macOS always, and on
1856    /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
1857    /// spawn one and could not be given a scope never reaches here, because the launch is
1858    /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
1859    ///
1860    /// Its presence decides each spawn's `RLIMIT_NPROC` ([`Self::nproc_bound`]): a spawn that
1861    /// joins it is bounded by `pids.max` alone. A native tool can still be dispatched on a
1862    /// session whose policy required no scope, so a spawn path never assumes one is here.
1863    pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
1864    /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
1865    /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
1866    /// only at the next agent turn.
1867    pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
1868}
1869
1870impl ShellEnforcement {
1871    /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
1872    ///
1873    /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
1874    /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
1875    /// caller creates and attaches with [`Self::with_host_bounding`].
1876    ///
1877    /// `declared` is the already-combined containment floor for the session. It is what decides
1878    /// whether a sealed-capable host actually installs a composed root — see [`applied_tier`].
1879    ///
1880    /// `probe` is the session's own [`HostProbe`], taken by `runtime::stage_session` and carried
1881    /// on the `StagedSession`. Passed in rather than taken here so the mechanisms this installs
1882    /// are the ones the session's trace already claims.
1883    pub(crate) fn resolve(
1884        policy: &CapabilityPolicy,
1885        declared: murmur_artifact::ContainmentClass,
1886        probe: HostProbe,
1887    ) -> Result<Self, String> {
1888        let tier = applied_tier(probe.tier(), declared);
1889        let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
1890        let network_allow_rules =
1891            crate::network_policy::parse_network_allow_rules(&policy.network_allow)
1892                .map_err(|error| error.to_string())?;
1893        let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
1894        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
1895        // The `DT_NEEDED`-closure files (individual files → non-listable) plus one grant
1896        // per author-declared `interpreter_runtime` directory (each with its own `list_dir`). A
1897        // directory not named in the manifest never receives a rule, regardless of what it holds.
1898        let mut landlock_grants =
1899            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
1900        landlock_grants.extend(resolve_interpreter_runtime_grants(
1901            &policy.shell_interpreter_runtime,
1902        ));
1903        // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
1904        // composed root and then denied by the Landlock ruleset installed inside it — see
1905        // `resolve_staged_runtime_landlock_grants`.
1906        landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
1907        // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
1908        // directory — the composed root binds them but Landlock inside the root still denies
1909        // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
1910        // `scoped` capsule runs on) the same grant would expose real host directory shape. The
1911        // resolver applies that gate itself and returns nothing on every other tier.
1912        landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
1913        // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
1914        // mounted-but-denied bug one directory over, and the reason TLS certificate verification
1915        // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
1916        // `KernelFull` these rules would apply to the *host's* /etc.
1917        landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
1918        let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
1919        let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
1920        Ok(Self {
1921            tier,
1922            network_allow_ips,
1923            network_allow_rules,
1924            egress_tcp_ports,
1925            unix_sockets_allowed: policy.unix_sockets_allowed,
1926            workdir_exec: policy.workdir_exec_allowed,
1927            landlock_grants,
1928            sealed_bind_dirs,
1929            staged_runtime_dirs,
1930            resource_limits: policy.resources,
1931            cgroup_scope: None,
1932            workdir_guard: None,
1933        })
1934    }
1935
1936    /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
1937    /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
1938    ///
1939    /// Separate from [`Self::resolve`] because both are *live* per-session resources with
1940    /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
1941    /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
1942    /// creation so the fail-closed launch refusal can happen with a typed error before this is
1943    /// ever reached.
1944    pub(crate) fn with_host_bounding(
1945        mut self,
1946        cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
1947        workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
1948    ) -> Self {
1949        self.cgroup_scope = cgroup_scope;
1950        self.workdir_guard = workdir_guard;
1951        self
1952    }
1953
1954    /// The `RLIMIT_NPROC` decision for one spawn, made in the parent before its `pre_exec`
1955    /// closure is installed.
1956    ///
1957    /// [`crate::resources::NprocBound::CgroupPidsMax`] when the spawn will join a scope that
1958    /// places it, because both closures that receive this run the join fatally before `execve`.
1959    /// Otherwise [`crate::resources::NprocBound::Headroom`], with a baseline measured by
1960    /// `crate::resources::uid_task_count` on this call — a `/proc` walk on Linux — so call it
1961    /// once per spawn and never from inside `pre_exec`.
1962    pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
1963        if self
1964            .cgroup_scope
1965            .as_ref()
1966            .is_some_and(|scope| scope.places_subprocesses())
1967        {
1968            crate::resources::NprocBound::CgroupPidsMax
1969        } else {
1970            crate::resources::NprocBound::Headroom {
1971                baseline: crate::resources::uid_task_count().unwrap_or(0),
1972            }
1973        }
1974    }
1975
1976    /// The latched workdir-size breach, if the guard has seen one.
1977    pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
1978        self.workdir_guard.as_ref().and_then(|guard| guard.breach())
1979    }
1980
1981    /// Refuse to spawn once the workdir ceiling has been crossed.
1982    ///
1983    /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
1984    /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
1985    /// the periodic check notices the breach, and the very next subprocess never starts.
1986    pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
1987        match self.workdir_breach() {
1988            Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
1989            None => Ok(()),
1990        }
1991    }
1992
1993    /// The permanent macOS/non-Linux value, and also what tests should construct directly to
1994    /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
1995    /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
1996    /// test code calls this directly today — production always goes through `resolve()`,
1997    /// which independently arrives at the same tier value on non-Linux hosts.)
1998    #[cfg_attr(not(test), allow(dead_code))]
1999    pub(crate) fn environment_only() -> Self {
2000        Self {
2001            tier: EnforcementTier::EnvironmentOnly,
2002            network_allow_ips: Vec::new(),
2003            network_allow_rules: Vec::new(),
2004            egress_tcp_ports: Vec::new(),
2005            // Denied, matching every other grant this constructor zeroes out. Inert on this tier
2006            // (no seccomp filter is installed at all), but it must not read as "allowed".
2007            unix_sockets_allowed: false,
2008            // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
2009            // Landlock domain exists to withhold anything from), but it must not read as "allowed".
2010            workdir_exec: false,
2011            landlock_grants: Vec::new(),
2012            sealed_bind_dirs: Vec::new(),
2013            staged_runtime_dirs: Vec::new(),
2014            // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
2015            // zeroing them out here would misrepresent what a real macOS host does.
2016            resource_limits: crate::resources::HostResourceLimits::default(),
2017            cgroup_scope: None,
2018            workdir_guard: None,
2019        }
2020    }
2021}
2022
2023/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
2024/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
2025/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
2026/// one or two concise sentences.
2027///
2028/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
2029/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
2030/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
2031/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
2032/// dynamically link, and the only writable path outside the workdir is `/dev/null`, one of the
2033/// three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp additionally refuses the
2034/// `socket(2)` domains in `denied_socket_domains` — closing the `/var/run/docker.sock` path, which
2035/// no Landlock ABI mediates for pathname sockets. The forked shell child also
2036/// drops every Linux capability before `execve` (see `linux_enforce::drop_all_capabilities`).
2037/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
2038/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
2039const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
2040resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
2041derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
2042shared libraries — nothing writable, no directory granted wholesale), plus a fixed device set \
2043every capsule gets unconditionally: /dev/null read+write (the only writable path outside the \
2044workdir), \
2045/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
2046raw block devices stay denied. The workdir's own grant also \
2047withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
2048capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
2049capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
2050defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
2051profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
2052and the forked shell child \
2053drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
2054denied, not absent: stat, access and readlink on an ungranted path still succeed.";
2055
2056/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
2057/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
2058/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
2059const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
2060host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
2061namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir \
2062the only writable path, a private /dev tmpfs carrying the OCI default device set), with Landlock \
2063and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
2064rather than denied — with one documented exception: /proc is a bind of the host's, not a masked \
2065private procfs, because mounting one unprivileged needs a PID namespace this tier does not create, \
2066so host process metadata stays visible there exactly as it is under scoped.";
2067
2068const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
2069lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
2070kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
2071verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
2072read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
2073mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
2074exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
2075capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
2076rule needs no Landlock and so applies identically on this tier, but it has not been verified \
2077either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
2078profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
2079tier too. The forked shell child still drops every Linux capability and \
2080sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
2081verified either. Treat shell subprocess isolation as experimental on this host.";
2082
2083const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
2084platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
2085enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
2086permanent on this platform.";
2087
2088/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
2089///
2090///   - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
2091///     is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
2092///   - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
2093///     (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
2094///   - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
2095///     sandboxing primitive at all on this platform, and this is permanent. Also still calls
2096///     `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
2097///     nothing is kernel-enforced.
2098///
2099/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
2100/// assert what each tier warns without capturing stderr.
2101pub(crate) fn tier_warning(
2102    tier: EnforcementTier,
2103    shell_allow_is_empty: bool,
2104) -> Option<(&'static str, &'static str)> {
2105    if shell_allow_is_empty {
2106        return None;
2107    }
2108    match tier {
2109        EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
2110        EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
2111        EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
2112        EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
2113    }
2114}
2115
2116const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn native subprocesses, but \
2117this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
2118tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
2119capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
2120does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
2121This is permanent on this platform, not a placeholder for a future slice.";
2122
2123/// Pure decision for the aggregate-bounding warning, split out of
2124/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
2125/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
2126///
2127/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
2128/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
2129/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
2130/// there is no running session left to warn about.
2131pub(crate) fn aggregate_bounding_warning(
2132    is_linux: bool,
2133    requires_bounding: bool,
2134    has_scope: bool,
2135) -> Option<(&'static str, &'static str)> {
2136    if is_linux || !requires_bounding || has_scope {
2137        return None;
2138    }
2139    Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
2140}
2141
2142/// Fires at every launch, not just once.
2143pub(crate) fn warn_for_missing_aggregate_bounding(
2144    workdir: &Path,
2145    requires_bounding: bool,
2146    has_scope: bool,
2147) {
2148    let is_linux = cfg!(target_os = "linux");
2149    if let Some((code, message)) =
2150        aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
2151    {
2152        let link = security_warning_link(code);
2153        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2154        crate::agent::append_bootstrap_log(
2155            workdir,
2156            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2157        );
2158    }
2159}
2160
2161/// Fires at every launch, not just once.
2162pub(crate) fn warn_for_enforcement_tier(
2163    tier: EnforcementTier,
2164    workdir: &Path,
2165    policy: &CapabilityPolicy,
2166) {
2167    if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
2168        let link = security_warning_link(code);
2169        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2170        crate::agent::append_bootstrap_log(
2171            workdir,
2172            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2173        );
2174    }
2175    if tier == EnforcementTier::EnvironmentOnly {
2176        crate::runtime::warn_if_bash_network_bypass(workdir, policy);
2177    }
2178}
2179
2180/// Handle to the (possibly no-op) side-channel machinery attached to one subprocess spawn. On
2181/// Linux tiers, the background thread is already running by the time this is returned by
2182/// `prepare_enforcement` — started BEFORE the caller calls `.spawn()`, not after.
2183///
2184/// This ordering is required, not incidental. The child builds its network namespace inside
2185/// `pre_exec` and hands the namespace's listening sockets back over a `SOCK_DGRAM` socketpair;
2186/// `std::process::Command::spawn()`'s parent side then blocks internally (via a close-on-exec
2187/// error pipe) until the child's `execve` resolves. If the receiving thread only started after
2188/// `.spawn()` returned, the capsule's very first outbound connection could beat the egress proxy
2189/// to its own socket. Starting the thread first — racing concurrently with the fork/exec inside
2190/// `.spawn()`, not gated behind it — closes that window.
2191///
2192/// The name is historical: nothing is "supervised" here any more (the seccomp-notify loops it once
2193/// ran are retired — see this module's header). What is left is the fd hand-off, the egress
2194/// proxy's lifetime, and the child's diagnostic pipe.
2195#[derive(Debug)]
2196pub(crate) enum SupervisorHandle {
2197    Noop,
2198    #[cfg(target_os = "linux")]
2199    Linux {
2200        /// Carries the started egress proxy back from the receiving thread, so the proxy's
2201        /// lifetime ends with the subprocess tree it serves rather than outliving it. `None`
2202        /// travels through here too — the hand-off can fail, and that is reported (and fatal to
2203        /// the spawn) on its own path.
2204        proxy_rx: std::sync::mpsc::Receiver<Option<crate::egress_proxy::EgressProxyHandle>>,
2205        /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
2206        /// child's `pre_exec` closure. On a successful `execve` the write end closes
2207        /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
2208        /// setup failure the child writes the real error message here before returning `Err`,
2209        /// letting `execute_shell` fold that legible detail into the error it returns instead of
2210        /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
2211        diag_read: std::os::fd::OwnedFd,
2212    },
2213}
2214
2215impl SupervisorHandle {
2216    /// Shuts the session's egress proxy down, with a short bound so the shell call never hangs.
2217    /// In practice the receive returns immediately: the thread's only blocking step is the
2218    /// namespace-socket hand-off, which completed before the child could `execve` at all — long
2219    /// before the caller finished waiting on the child.
2220    ///
2221    /// A proxy outliving its namespace would be serving sockets whose peers no longer exist, so
2222    /// this is the teardown, not merely a thread join.
2223    pub(crate) fn join_best_effort(self) {
2224        match self {
2225            SupervisorHandle::Noop => {}
2226            #[cfg(target_os = "linux")]
2227            SupervisorHandle::Linux { proxy_rx, .. } => {
2228                if let Ok(Some(proxy)) = proxy_rx.recv_timeout(std::time::Duration::from_secs(5)) {
2229                    proxy.shutdown();
2230                }
2231                // Deliberately not joining the underlying `JoinHandle`: dropping it without
2232                // joining leaves the thread detached (it keeps running to completion in the
2233                // background, reclaimed by the OS on exit), which is fine here since the value
2234                // received above is the only thing it produces.
2235            }
2236        }
2237    }
2238
2239    /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
2240    /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
2241    /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
2242    /// there is zero cost there.
2243    ///
2244    /// The caller must have already released every *parent-side* copy of the pipe's write end
2245    /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
2246    /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
2247    /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
2248    /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
2249    /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
2250    /// failing).
2251    pub(crate) fn read_diagnostic(&self) -> Option<String> {
2252        match self {
2253            SupervisorHandle::Noop => None,
2254            #[cfg(target_os = "linux")]
2255            SupervisorHandle::Linux { diag_read, .. } => {
2256                use std::os::fd::AsRawFd;
2257                linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
2258            }
2259        }
2260    }
2261}
2262
2263// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
2264// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
2265// test builds; production `prepare_enforcement` has no bypass or injection point.
2266#[cfg(test)]
2267thread_local! {
2268    pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
2269        const { std::cell::Cell::new(false) };
2270}
2271
2272#[cfg(test)]
2273fn forced_prepare_failure() -> Result<(), String> {
2274    if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
2275        return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
2276    }
2277    Ok(())
2278}
2279
2280// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
2281// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
2282// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
2283// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
2284// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
2285// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
2286// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
2287// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
2288#[cfg(all(test, target_os = "linux"))]
2289thread_local! {
2290    pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
2291        const { std::cell::Cell::new(false) };
2292    pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
2293        const { std::cell::Cell::new(false) };
2294    /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
2295    /// only fail on a host that can attempt it at all, and the thing under test — that a
2296    /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
2297    /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
2298    /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
2299    pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
2300        const { std::cell::Cell::new(false) };
2301}
2302
2303/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
2304/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
2305/// and `runtime`'s (which checks what the dispatch layer does with it).
2306#[cfg(all(test, target_os = "linux"))]
2307pub(crate) struct ForceSealedRootFailureGuard;
2308
2309#[cfg(all(test, target_os = "linux"))]
2310impl ForceSealedRootFailureGuard {
2311    pub(crate) fn new() -> Self {
2312        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
2313        Self
2314    }
2315}
2316
2317#[cfg(all(test, target_os = "linux"))]
2318impl Drop for ForceSealedRootFailureGuard {
2319    fn drop(&mut self) {
2320        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
2321    }
2322}
2323
2324/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
2325/// half of the sealed mechanism still runs for real against it (the composed root is planned and
2326/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
2327/// which is what makes these tests run identically on a sealed-capable host and on one that could
2328/// never reach `KernelSealed` at all.
2329#[cfg(all(test, target_os = "linux"))]
2330pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
2331    ShellEnforcement {
2332        tier: EnforcementTier::KernelSealed,
2333        ..ShellEnforcement::environment_only()
2334    }
2335}
2336
2337/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
2338/// exists — to `command`, so they apply to the spawned process and everything it forks.
2339///
2340/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
2341/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
2342/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. Splitting
2343/// this out is what lets the two subprocess spawn paths converge: `execute_shell` gets it as
2344/// part of `prepare_enforcement`, and `dispatch_native_tool` — which installs no seccomp or
2345/// Landlock at all, a standing gap — gets it on its own.
2346///
2347/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
2348/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
2349/// allocation, no locking, no path resolution.
2350#[cfg(unix)]
2351pub(crate) fn attach_process_limits(
2352    command: &mut std::process::Command,
2353    enforcement: &ShellEnforcement,
2354) {
2355    use std::os::unix::process::CommandExt;
2356
2357    let limits = enforcement.resource_limits;
2358    let nproc = enforcement.nproc_bound();
2359    let cgroup_scope = enforcement.cgroup_scope.clone();
2360
2361    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
2362    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
2363    // scope exists, a single `write` to a descriptor opened by the parent before the fork —
2364    // no allocation, no locks, no path lookups.
2365    #[allow(unsafe_code)]
2366    unsafe {
2367        command.pre_exec(move || {
2368            crate::resources::apply_hard_rlimits(&limits, nproc)?;
2369            if let Some(scope) = cgroup_scope.as_ref() {
2370                scope.join_current_process()?;
2371            }
2372            Ok(())
2373        });
2374    }
2375}
2376
2377/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
2378/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
2379///
2380/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles both
2381/// spawn paths install *before* `pre_exec` runs exist so the exec'd program inherits them across
2382/// its own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
2383/// stdout/stderr, and `dispatch_native_tool` additionally writes its `ToolInput` JSON to the
2384/// subprocess's stdin and parses its `ToolResult` JSON back off stdout. Marking 0/1/2
2385/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
2386/// invocation on both paths, and there is nothing meaningful to reopen them onto: the pipes
2387/// themselves are the intended destination. The gap this constant closes is everything *above*
2388/// stdio, where inheritance was never intended and was only ever prevented by the empirical
2389/// accident of nothing else happening to be open at spawn time.
2390///
2391/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
2392/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
2393/// module carry.
2394#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2395pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
2396
2397/// Non-Linux counterpart of [`apply_fd_hygiene`]: a documented no-op.
2398///
2399/// `close_range(2)` is Linux-only, and this mirrors the shape `prepare_enforcement` already
2400/// uses on non-Linux targets — no `pre_exec` closure is installed at all, rather than one that
2401/// does nothing. macOS is `EnforcementTier::EnvironmentOnly` by construction; there is no
2402/// kernel primitive here to call.
2403#[cfg(not(target_os = "linux"))]
2404pub(crate) fn apply_fd_hygiene(_command: &mut std::process::Command) {}
2405
2406/// Attaches the fd-hygiene step — mark every fd >= [`FD_HYGIENE_FIRST_FD`] close-on-exec — to a
2407/// `Command` that is about to be spawned, for spawn paths that do **not** go through
2408/// `prepare_enforcement`.
2409///
2410/// This exists so `runtime::dispatch_native_tool` (which has no `ShellEnforcement`, no tier and
2411/// no kernel sandboxing of its own) gets exactly the same fd-inheritance guarantee
2412/// `execute_shell` gets, without pulling the seccomp/Landlock apparatus into it. It is
2413/// deliberately a free function over `&mut Command` taking no policy input: fd hygiene is
2414/// unconditional — it is not derived from `CapabilityPolicy`, the manifest, or the enforcement
2415/// tier, and there is no configuration under which a subprocess should inherit an fd the
2416/// runtime did not deliberately hand it.
2417///
2418/// Do **not** call this on a `Command` that also goes through `prepare_enforcement`:
2419/// `linux_enforce::child_install_enforcement` already performs the same step as its first
2420/// statement, and `pre_exec` closures run in registration order, so a second one would be a
2421/// redundant syscall rather than an additional guarantee.
2422///
2423/// On a kernel older than 5.11 this makes `.spawn()` itself fail — see
2424/// [`linux_enforce::mark_inherited_fds_cloexec`] for that fail-closed consequence, which
2425/// applies identically here.
2426#[cfg(target_os = "linux")]
2427#[allow(unsafe_code)]
2428pub(crate) fn apply_fd_hygiene(command: &mut std::process::Command) {
2429    use std::os::unix::process::CommandExt;
2430
2431    // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
2432    // narrow pre_exec window where only async-signal-safe operations are permitted. Its body is
2433    // a single `close_range(2)` syscall plus, on failure only, one `io::Error` construction: no
2434    // allocation on the success path, no locks, no captured state at all.
2435    unsafe {
2436        command.pre_exec(linux_enforce::mark_inherited_fds_cloexec);
2437    }
2438}
2439
2440/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
2441/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
2442/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
2443/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
2444/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
2445/// no kernel sandbox at all.
2446#[cfg(not(target_os = "linux"))]
2447pub(crate) fn prepare_enforcement(
2448    command: &mut std::process::Command,
2449    enforcement: &ShellEnforcement,
2450    _accessible_workdir: &Path,
2451    _session_workdir: &Path,
2452) -> Result<SupervisorHandle, String> {
2453    #[cfg(test)]
2454    forced_prepare_failure()?;
2455
2456    debug_assert_eq!(
2457        enforcement.tier,
2458        EnforcementTier::EnvironmentOnly,
2459        "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly"
2460    );
2461    attach_process_limits(command, enforcement);
2462    Ok(SupervisorHandle::Noop)
2463}
2464
2465/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
2466/// submodule for the mechanics (socketpair fd-passing side channel + `pre_exec` seccomp/Landlock
2467/// installation + background namespace-socket receiving thread).
2468#[cfg(target_os = "linux")]
2469#[allow(unsafe_code)]
2470pub(crate) fn prepare_enforcement(
2471    command: &mut std::process::Command,
2472    enforcement: &ShellEnforcement,
2473    accessible_workdir: &Path,
2474    session_workdir: &Path,
2475) -> Result<SupervisorHandle, String> {
2476    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
2477    use std::os::unix::process::CommandExt;
2478
2479    #[cfg(test)]
2480    forced_prepare_failure()?;
2481
2482    if enforcement.tier == EnforcementTier::EnvironmentOnly {
2483        // No kernel sandbox to install, but the POSIX rlimit ceilings still apply — this tier is
2484        // only reachable on Linux from tests, and even there it must not read as "unbounded".
2485        attach_process_limits(command, enforcement);
2486        return Ok(SupervisorHandle::Noop);
2487    }
2488
2489    // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
2490    // is a normal synchronous error here — it names the path and returns before any subprocess is
2491    // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
2492    // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
2493    // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
2494    // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
2495    let landlock_fds = if matches!(
2496        enforcement.tier,
2497        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
2498    ) {
2499        Some(linux_enforce::open_landlock_fds(
2500            accessible_workdir,
2501            session_workdir,
2502            &enforcement.landlock_grants,
2503            &landlock_device_grants(enforcement.tier),
2504            enforcement.workdir_exec,
2505            enforcement.tier,
2506        )?)
2507    } else {
2508        None
2509    };
2510
2511    // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
2512    // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
2513    // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
2514    // thread that no longer exists — happens now instead, synchronously, where a failure names
2515    // itself. See `crate::sealed` for the plan/execute split.
2516    let sealed_spec = if enforcement.tier == EnforcementTier::KernelSealed {
2517        Some(build_sealed_root(
2518            accessible_workdir,
2519            session_workdir,
2520            &enforcement.sealed_bind_dirs,
2521            &enforcement.staged_runtime_dirs,
2522        )?)
2523    } else {
2524        None
2525    };
2526
2527    let mut fds = [0i32; 2];
2528    // SAFETY: `fds` is a 2-element stack array, exactly the size `socketpair` writes into;
2529    // no other precondition applies.
2530    let rc = unsafe {
2531        libc::socketpair(
2532            libc::AF_UNIX,
2533            libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
2534            0,
2535            fds.as_mut_ptr(),
2536        )
2537    };
2538    if rc != 0 {
2539        return Err(format!(
2540            "sandbox: socketpair() failed: {}",
2541            std::io::Error::last_os_error()
2542        ));
2543    }
2544    // SAFETY: both fds were just returned by the successful socketpair() call above; nothing
2545    // else has taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
2546    let (parent_sock, child_sock) =
2547        unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
2548
2549    // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
2550    // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
2551    // nothing written (the parent's read then returns immediate EOF — zero cost on the success
2552    // path), while any `pre_exec` setup failure writes its real message here before the closure
2553    // returns `Err`. Raw `pipe2` mirrors the raw `socketpair` convention used just above.
2554    let mut diag_fds = [0i32; 2];
2555    // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
2556    // other precondition applies.
2557    let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
2558    if rc != 0 {
2559        return Err(format!(
2560            "sandbox: pipe2() for enforcement diagnostics failed: {}",
2561            std::io::Error::last_os_error()
2562        ));
2563    }
2564    // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
2565    // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
2566    let (diag_read, diag_write) = unsafe {
2567        (
2568            OwnedFd::from_raw_fd(diag_fds[0]),
2569            OwnedFd::from_raw_fd(diag_fds[1]),
2570        )
2571    };
2572
2573    // Everything the child's namespace construction needs, resolved here in the parent: the
2574    // `pre_exec` window permits no allocation, and `getuid()` in particular has to be read
2575    // *before* the `unshare` — inside a fresh user namespace with no map written yet it reports
2576    // the overflow id, and writing that back is refused on every host.
2577    let netns_plan =
2578        crate::network_namespace::CapsuleNetnsPlan::resolve(enforcement.egress_tcp_ports.clone());
2579    let expected_namespace_sockets = netns_plan.socket_count();
2580    let egress_policy = crate::egress_proxy::EgressPolicy::new(
2581        enforcement.network_allow_rules.clone(),
2582        enforcement.network_allow_ips.clone(),
2583    );
2584
2585    let tier = enforcement.tier;
2586    // Copied out before the `move` closure below takes ownership of everything it touches, the
2587    // same clone-before-move shape `tier` and the Landlock fds already use. Note the fd-passing
2588    // socketpair above is created in the *parent*, before fork, so the child's own `sendmsg` over
2589    // it is unaffected by a filter that denies `socket(AF_UNIX, ...)`.
2590    let unix_sockets_allowed = enforcement.unix_sockets_allowed;
2591    // Same clone-before-move shape. `HostResourceLimits` and `NprocBound` are `Copy`, and the
2592    // `RLIMIT_NPROC` decision is made here because its baseline is a `/proc` walk; the cgroup scope
2593    // is an `Arc` whose `cgroup.procs` descriptor was opened in the parent at scope-creation time,
2594    // so the child performs no path lookup to join it.
2595    let resource_limits = enforcement.resource_limits;
2596    let nproc = enforcement.nproc_bound();
2597    let cgroup_scope = enforcement.cgroup_scope.clone();
2598
2599    // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
2600    // narrow pre_exec window where only async-signal-safe operations are permitted. It performs
2601    // the capability-dropping `prctl`/`capset` sequence (bounding-set drop, capset clear, and the
2602    // explicit `no_new_privs` `prctl`) plus one allocation-free `open`/`read`/`close` of
2603    // `/proc/sys/kernel/cap_last_cap`, libseccomp filter construction/load (kernel syscalls), one
2604    // `sendmsg` batch handing the network namespace's sockets to the parent over `child_sock`, and
2605    // (KernelFull only)
2606    // Landlock ruleset construction/`restrict_self` against already-open fds (also kernel
2607    // syscalls) — no `open()`/`canonicalize()` beyond that one `cap_last_cap` read, no locks
2608    // beyond what those syscalls need. On any failure it writes the real error message to the
2609    // CLOEXEC diagnostic pipe (best-effort, bounded, raw `write` loop) before returning `Err`.
2610    // `child_sock`, `diag_write`, and the Landlock fds are moved in and close automatically when
2611    // the closure body finishes.
2612    unsafe {
2613        command.pre_exec(move || {
2614            // Ordered first, before any seccomp filter is installed: these are the bounds that
2615            // must hold even if a later step in this closure fails, and `prlimit64`/`setrlimit`
2616            // plus `write` are all already in `SECCOMP_SYSCALL_ALLOWLIST` either way.
2617            if let Err(error) = crate::resources::apply_hard_rlimits(&resource_limits, nproc) {
2618                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
2619                return Err(error);
2620            }
2621            if let Some(scope) = cgroup_scope.as_ref() {
2622                if let Err(error) = scope.join_current_process() {
2623                    linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
2624                    return Err(error);
2625                }
2626            }
2627
2628            let sock_fd = child_sock.as_raw_fd();
2629            match linux_enforce::child_install_enforcement(
2630                tier,
2631                &netns_plan,
2632                sealed_spec.as_ref(),
2633                landlock_fds.as_ref(),
2634                sock_fd,
2635                unix_sockets_allowed,
2636            ) {
2637                Ok(()) => Ok(()),
2638                Err(error) => {
2639                    linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
2640                    Err(error)
2641                }
2642            }
2643        });
2644    }
2645
2646    // Start the namespace-socket receiving thread now — BEFORE the caller calls `.spawn()`. See
2647    // `SupervisorHandle`'s doc comment for why this ordering (not "after spawn") is required.
2648    Ok(linux_enforce::start_supervisor(
2649        parent_sock,
2650        egress_policy,
2651        expected_namespace_sockets,
2652        diag_read,
2653    ))
2654}
2655
2656/// Which fixed device set Landlock grants, keyed on the tier.
2657///
2658/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
2659/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
2660/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
2661/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
2662/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
2663///
2664/// The parent opens these paths on the *host*, before the namespace exists; because the composed
2665/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
2666/// are the inodes the capsule reaches.
2667#[cfg(target_os = "linux")]
2668fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
2669    match tier {
2670        EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
2671            .iter()
2672            .map(|device| CapsuleDeviceGrant {
2673                path: device.path,
2674                writable: device.writable,
2675            })
2676            .collect(),
2677        _ => CAPSULE_DEVICE_GRANTS.to_vec(),
2678    }
2679}
2680
2681/// Creates (idempotently) the directory inside the session workdir that backs `/tmp` in the
2682/// composed root, returning its host path.
2683///
2684/// Two callers need it and neither may assume the other ran first: `build_sealed_root` binds it at
2685/// `/tmp`, and `open_landlock_fds` opens it to give that bind a Landlock rule. They are called in
2686/// that reverse order by `prepare_enforcement` today, so the creation lives here rather than in
2687/// either of them — one `create_dir_all`, one message, no ordering dependency between the two.
2688/// See [`crate::sealed::SEALED_TMP_DIR_NAME`] for why `/tmp` is workdir-backed rather than a second
2689/// tmpfs.
2690#[cfg(target_os = "linux")]
2691fn ensure_sealed_tmp_store(session_workdir: &Path) -> Result<PathBuf, String> {
2692    let tmp_store = session_workdir.join(crate::sealed::SEALED_TMP_DIR_NAME);
2693    std::fs::create_dir_all(&tmp_store).map_err(|error| {
2694        format!(
2695            "sealed: failed to create the workdir-backed /tmp store at {}: {error}",
2696            tmp_store.display()
2697        )
2698    })?;
2699    Ok(tmp_store)
2700}
2701
2702/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
2703/// of the host's, returning their host paths in [`crate::sealed::SEALED_ETC_PATHS`] order.
2704///
2705/// Sibling of [`ensure_sealed_tmp_store`] in every respect that matters: plain `std::fs`, no
2706/// privilege, and two callers in different phases that must not depend on each other's ordering —
2707/// `build_sealed_root` binds these files, `open_landlock_fds` opens them to grant them. Content is
2708/// a pure function of the workdir, so both calls write the same bytes.
2709///
2710/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
2711/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
2712/// nothing the parent could have done for it.
2713///
2714/// The rewrite is deliberately in place (`fs::write` truncates; it does not replace). The Landlock
2715/// rule the earlier caller took is bound to the file's inode, so writing a fresh file over the path
2716/// would leave that rule pointing at an inode the composed root no longer binds — `/etc/passwd`
2717/// mounted and `EACCES`.
2718#[cfg(target_os = "linux")]
2719fn ensure_sealed_identity_files(session_workdir: &Path) -> Result<Vec<PathBuf>, String> {
2720    use std::os::unix::fs::MetadataExt;
2721
2722    let staging = session_workdir.join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME);
2723    std::fs::create_dir_all(&staging).map_err(|error| {
2724        format!(
2725            "sealed: failed to create the synthetic /etc staging directory at {}: {error}",
2726            staging.display()
2727        )
2728    })?;
2729
2730    // The workdir was created by this process, so its owner is the identity the capsule's
2731    // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
2732    let owner = std::fs::metadata(session_workdir).map_err(|error| {
2733        format!(
2734            "sealed: failed to stat the session workdir {} for the capsule's uid/gid: {error}",
2735            session_workdir.display()
2736        )
2737    })?;
2738    // The same string `build_shell_env` puts in `$HOME`, from the same constant, resolved against
2739    // the same directory: the synthetic home lives in the session workdir, so a `pw_dir` composed
2740    // against the accessible one would name a directory that does not exist.
2741    let home = session_workdir.join(crate::shell::SYNTHETIC_HOME_DIR_NAME);
2742    let home = home.to_string_lossy().into_owned();
2743    let identity = crate::sealed::SealedAccountIdentity {
2744        uid: owner.uid(),
2745        gid: owner.gid(),
2746        home: &home,
2747    };
2748
2749    let mut written = Vec::new();
2750    for entry in crate::sealed::SEALED_ETC_PATHS {
2751        let Some(file) = entry.synthetic else {
2752            continue;
2753        };
2754        let path = crate::sealed::synthetic_etc_source(session_workdir, file);
2755        std::fs::write(&path, file.render(&identity)?).map_err(|error| {
2756            format!(
2757                "sealed: failed to write the synthetic {} at {}: {error}",
2758                entry.path,
2759                path.display()
2760            )
2761        })?;
2762        written.push(path);
2763    }
2764    Ok(written)
2765}
2766
2767/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
2768/// host layout, create the workdir-backed `/tmp` store, and lower the plan into the C-string form
2769/// the forked child executes.
2770///
2771/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
2772/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
2773/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
2774/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
2775/// declared-vs-achieved mismatch.
2776///
2777/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
2778/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
2779/// error would return `Err(String)`, which converts to the ordinary, retryable
2780/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
2781/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
2782/// classification for a capsule that did not get a runtime tree it declared.
2783#[cfg(target_os = "linux")]
2784fn build_sealed_root(
2785    accessible_workdir: &Path,
2786    session_workdir: &Path,
2787    extra_read_only: &[PathBuf],
2788    staged_runtime_read_only: &[PathBuf],
2789) -> Result<crate::sealed::SealedRootSpec, String> {
2790    use crate::sealed;
2791
2792    // The workdir must be absolute for its path to mean the same thing inside the composed root,
2793    // and `launch_session` always creates it as one — but the composed root's whole design rests
2794    // on it, so it is checked rather than assumed.
2795    if !accessible_workdir.is_absolute() {
2796        return Err(format!(
2797            "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
2798             its own absolute path",
2799            accessible_workdir.display()
2800        ));
2801    }
2802
2803    let base = sealed::choose_root_base(
2804        accessible_workdir,
2805        sealed::SEALED_ROOT_BASE_CANDIDATES,
2806        |path| path.is_dir(),
2807    )
2808    .ok_or_else(|| {
2809        format!(
2810            "sealed: no usable base directory for the composed root; none of {:?} exists outside \
2811             the session workdir's own path",
2812            sealed::SEALED_ROOT_BASE_CANDIDATES
2813        )
2814    })?;
2815
2816    // `/tmp` inside the composed root is backed by this directory, so it is created here rather
2817    // than from inside `pre_exec`. `open_landlock_fds` — which runs earlier and must open the same
2818    // directory to grant it — creates it through the same idempotent helper.
2819    ensure_sealed_tmp_store(session_workdir)?;
2820
2821    // `/etc/passwd` and `/etc/group` inside the composed root are binds of these files, so they
2822    // exist on disk before the plan that names them is built — and long before the child executes
2823    // it. Same idempotent helper `open_landlock_fds` called earlier to grant them.
2824    ensure_sealed_identity_files(session_workdir)?;
2825
2826    let plan = sealed::plan_composed_root(
2827        accessible_workdir,
2828        session_workdir,
2829        &base,
2830        extra_read_only,
2831        staged_runtime_read_only,
2832        &sealed::RealHostLayout,
2833    );
2834    sealed::build_sealed_root_spec(&plan)
2835}
2836
2837/// Linux-only mechanics: fd-passing side channel, seccomp filter construction, Landlock
2838/// application, and the background thread that receives the capsule's network-namespace sockets.
2839///
2840/// Nothing here uses `libseccomp`'s notify facility. `connect`/`sendto` are enforced by the
2841/// network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which decide
2842/// from kernel-held state. A `Notify` rule would reintroduce a `/proc/<pid>/mem` read on the hot
2843/// path, where the value read can change between the decision and the kernel's own dereference.
2844#[cfg_attr(target_os = "linux", allow(unsafe_code))]
2845#[cfg(target_os = "linux")]
2846mod linux_enforce {
2847    use std::io;
2848    use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
2849    use std::path::Path;
2850
2851    use landlock::{make_bitflags, AccessFs, BitFlags};
2852
2853    use super::{EnforcementTier, LandlockGrant, SupervisorHandle};
2854
2855    /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
2856    /// naming the failed step is far shorter than this; the bound just keeps the best-effort
2857    /// `write`/`read` loops trivially terminating.
2858    const MAX_DIAG_LEN: usize = 1024;
2859
2860    /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
2861    /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
2862    /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
2863    /// never a path to re-open there.
2864    pub(super) struct OpenLandlockGrant {
2865        fd: OwnedFd,
2866        list_dir: bool,
2867        executable: bool,
2868    }
2869
2870    /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
2871    /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
2872    /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
2873    /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
2874    /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
2875    /// their bits must not be confusable.
2876    pub(super) struct OpenDeviceGrant {
2877        fd: OwnedFd,
2878        writable: bool,
2879    }
2880
2881    /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
2882    /// workdir's fd (scoped by [`workdir_access_rights`]), the workdir-backed `/tmp` store's fd on
2883    /// `KernelSealed`, each successfully-opened grant fd, and each successfully-opened fixed-device
2884    /// fd.
2885    /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
2886    /// against these fds without performing a single `open()`.
2887    pub(super) struct LandlockChildFds {
2888        workdir_fd: OwnedFd,
2889        /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
2890        /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
2891        /// but its argument — has the whole workdir rule decided for it before fork.
2892        workdir_exec: bool,
2893        /// `<workdir>/.mur-tmp` — the directory `plan_composed_root` bind-mounts at `/tmp` inside
2894        /// the composed root — opened on `KernelSealed` and `None` on every other tier, which has
2895        /// no composed root and therefore no such bind.
2896        ///
2897        /// It needs an fd of its own even though it lives *inside* the workdir: Landlock resolves a
2898        /// path by walking dentries up to each mount root, so an access to `/tmp/x` inside the root
2899        /// is matched against the `.mur-tmp` inode (the bind's root) and then against the composed
2900        /// root's own `/tmp` mountpoint — never against the workdir inode. The workdir's rule
2901        /// therefore does not reach the bind, and without this fd every `/tmp` access is `EACCES`
2902        /// on a mount that is genuinely writable.
2903        sealed_tmp_fd: Option<OwnedFd>,
2904        /// The parent-written synthetic `/etc/passwd`/`/etc/group`
2905        /// ([`crate::sealed::SEALED_ETC_STAGING_DIR_NAME`]), opened on `KernelSealed` and empty on
2906        /// every other tier.
2907        ///
2908        /// Exactly the [`Self::sealed_tmp_fd`] problem, one directory over. The grant list already
2909        /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
2910        /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
2911        /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
2912        /// at the bound file's own inode, so without these fds both account databases would be
2913        /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
2914        ///
2915        /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
2916        /// nothing inside a capsule has any business editing an account database.
2917        sealed_identity_fds: Vec<OwnedFd>,
2918        grants: Vec<OpenLandlockGrant>,
2919        devices: Vec<OpenDeviceGrant>,
2920    }
2921
2922    impl LandlockChildFds {
2923        /// The workdir-backed `/tmp` fd, for the tier-gate test. Keeps the struct's fields private
2924        /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
2925        #[cfg(test)]
2926        pub(super) fn sealed_tmp_fd(&self) -> Option<&OwnedFd> {
2927            self.sealed_tmp_fd.as_ref()
2928        }
2929
2930        /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
2931        /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
2932        #[cfg(test)]
2933        pub(super) fn sealed_identity_fd_count(&self) -> usize {
2934            self.sealed_identity_fds.len()
2935        }
2936    }
2937
2938    /// The Landlock ABI v1 rights granted on the capsule **workdir's own** `PathBeneath` rule,
2939    /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
2940    /// declared `capabilities.filesystem.workdir_exec: true`.
2941    ///
2942    /// Spelled out variant by variant rather than `AccessFs::from_all(ABI::V1)` for two reasons:
2943    /// `from_all` hides what is actually handed to the capsule, and four of ABI v1's thirteen
2944    /// rights are withheld from this constant.
2945    ///
2946    ///   - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
2947    ///     makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
2948    ///     on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
2949    ///     cannot run under any name — including an allowlisted basename, the exact bypass a prior
2950    ///     release shipped. The cost: a binary the capsule legitimately compiled in its workdir
2951    ///     cannot run either, which is what `workdir_exec: true` buys back.
2952    ///   - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
2953    ///     whole scope. A capsule running as root could `mknod` a node for the host's own disk
2954    ///     (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
2955    ///     permits, because the new inode lives beneath a granted path — then `open()` it and
2956    ///     read the raw host filesystem underneath every other restriction. No known workload
2957    ///     creates device nodes in its working tree.
2958    ///   - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
2959    ///     filesystem object with a kernel-side identity beyond a regular file), but unlike the
2960    ///     two device rights this one is a genuine open question: some build tooling and some
2961    ///     language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
2962    ///     that breaks those is not a fix. The manual acceptance procedure in
2963    ///     `docs/content/reference/workdir-device-node-manual-verification.md` carries an
2964    ///     explicit unix-socket scenario for exactly this; if the team's
2965    ///     real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
2966    ///
2967    /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
2968    /// and a FIFO carries none of the raw-device risk above.
2969    ///
2970    /// Because `apply_landlock_scope`'s `handle_access` still declares the *full*
2971    /// `from_all(ABI::V1)` set, a right this rule withholds is not merely "not extra-granted" — it
2972    /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
2973    /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
2974    /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
2975    /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
2976    /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
2977    /// nowhere the capsule writes.
2978    pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
2979        WriteFile
2980            | ReadFile
2981            | ReadDir
2982            | RemoveDir
2983            | RemoveFile
2984            | MakeDir
2985            | MakeReg
2986            | MakeFifo
2987            | MakeSym
2988    });
2989
2990    /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
2991    /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
2992    ///
2993    /// A function rather than a second constant so the two cases cannot drift: adding a right to
2994    /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
2995    /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
2996    /// The whole point of the default is that the workdir grant is no longer name-based at all.
2997    pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
2998        if workdir_exec {
2999            WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
3000        } else {
3001            WORKDIR_ACCESS_RIGHTS_NO_EXEC
3002        }
3003    }
3004
3005    /// Probes real Landlock support by building a ruleset granting full access to `/` and
3006    /// calling `restrict_self()` **in a forked child**, so the host process is never placed
3007    /// into a Landlock domain by a mere capability check.
3008    ///
3009    /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
3010    /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
3011    /// mount family to the task and every descendant it will ever have, which is how Landlock
3012    /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
3013    /// before the sealed probe, and a session's probe is taken before any fork that would
3014    /// build a composed root, so restricting in-process poisons `sealed` on every host and every
3015    /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
3016    /// to suggest the runtime did it to itself.
3017    ///
3018    /// The ruleset is built in the parent because creating one and adding rules restricts
3019    /// nothing; only `restrict_self` applies it. That keeps everything the child does after
3020    /// the fork down to a `prctl` and one syscall.
3021    pub(super) fn probe_landlock_full_access() -> Option<bool> {
3022        use landlock::{
3023            Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
3024            RulesetCreatedAttr, RulesetStatus, ABI,
3025        };
3026
3027        // The child reports which of the three outcomes it reached through its exit status;
3028        // nothing else crosses the fork.
3029        const PROBE_FULLY_ENFORCED: i32 = 0;
3030        const PROBE_PARTIAL: i32 = 1;
3031        const PROBE_FAILED: i32 = 2;
3032
3033        let abi = ABI::V1;
3034        let access_all = AccessFs::from_all(abi);
3035
3036        let root_fd = PathFd::new("/").ok()?;
3037
3038        let ruleset = Ruleset::default()
3039            .set_compatibility(CompatLevel::BestEffort)
3040            .handle_access(access_all)
3041            .ok()?
3042            .create()
3043            .ok()?
3044            .add_rule(PathBeneath::new(root_fd, access_all))
3045            .ok()?;
3046
3047        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
3048        // confines itself to async-signal-safe work. Everything this child does is the
3049        // `restrict_self` syscall pair and `_exit`; the allocating part of building the
3050        // ruleset already happened above, in the parent.
3051        let pid = unsafe { libc::fork() };
3052        if pid < 0 {
3053            return None;
3054        }
3055        if pid == 0 {
3056            let code = match ruleset.restrict_self() {
3057                Ok(status) => {
3058                    if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
3059                        PROBE_FULLY_ENFORCED
3060                    } else {
3061                        PROBE_PARTIAL
3062                    }
3063                }
3064                Err(_) => PROBE_FAILED,
3065            };
3066            // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
3067            // which is what keeps the parent's state untouched.
3068            unsafe { libc::_exit(code) }
3069        }
3070
3071        let mut wait_status: libc::c_int = 0;
3072        // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
3073        let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
3074        if waited < 0 || !libc::WIFEXITED(wait_status) {
3075            return None;
3076        }
3077        match libc::WEXITSTATUS(wait_status) {
3078            PROBE_FULLY_ENFORCED => Some(true),
3079            PROBE_PARTIAL => Some(false),
3080            // A `restrict_self` that errored tells us nothing either way, which is the same
3081            // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
3082            _ => None,
3083        }
3084    }
3085
3086    /// Runs inside the forked child, pre-exec: builds the capsule's network namespace and hands
3087    /// its listening sockets to the parent over `child_sock_fd`, strips the child's Linux
3088    /// capabilities, installs the seccomp filter (the `socket()` domain denials keyed on
3089    /// `unix_sockets_allowed`, over a default-deny syscall allowlist), and (on
3090    /// `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope — which is also what
3091    /// enforces `capabilities.shell.allow`. Returning `Err` here aborts the exec (std's `Command`
3092    /// machinery propagates it back to the parent's `.spawn()` call as an `io::Error`) — the
3093    /// fail-closed path for setup failures that happen after fork.
3094    ///
3095    /// `child_sock_fd` carries exactly one hand-off: the namespace sockets.
3096    pub(super) fn child_install_enforcement(
3097        tier: EnforcementTier,
3098        netns_plan: &crate::network_namespace::CapsuleNetnsPlan,
3099        sealed_spec: Option<&crate::sealed::SealedRootSpec>,
3100        landlock_fds: Option<&LandlockChildFds>,
3101        child_sock_fd: RawFd,
3102        unix_sockets_allowed: bool,
3103    ) -> io::Result<()> {
3104        // FIRST, on every kernel tier: the capsule's own network namespace. It has to precede
3105        // everything below it for three separate reasons:
3106        //
3107        //   * `SECCOMP_MUST_STAY_DENIED` denies `unshare` and the filter installed further down
3108        //     denies `socket(AF_NETLINK)`, so this is the only window in which the namespace can
3109        //     be built at all — the same argument the composed root makes.
3110        //   * `drop_all_capabilities` (below) removes the `CAP_NET_ADMIN` that bringing `lo` up
3111        //     and installing the local route require. The namespace is configured while the child
3112        //     still holds them *inside its own new user namespace*, and never afterwards.
3113        //   * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests a second
3114        //     user namespace inside this one. A task in a descendant user namespace holds no
3115        //     capability in the ancestor, so once that has happened the capsule cannot reconfigure
3116        //     the network namespace it is confined to — which only holds if this runs first.
3117        //
3118        // This is what replaced the `connect`/`sendto` seccomp-notify interception. There is no
3119        // path that skips it and falls back: a host that cannot do this refused the launch back in
3120        // `stage_session` (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`).
3121        //
3122        // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
3123        // documents itself as running in, and the one `create_capsule_netns` requires.
3124        unsafe { crate::network_namespace::create_capsule_netns(netns_plan, child_sock_fd)? };
3125
3126        // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
3127        // composed root. This has to precede every *remaining* step in this closure, because
3128        // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
3129        // the filter below covers, permanently and deliberately.
3130        // The composed root is therefore built by this process while it still has its pre-filter
3131        // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
3132        // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
3133        // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
3134        // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
3135        // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
3136        if tier == EnforcementTier::KernelSealed {
3137            if let Some(spec) = sealed_spec {
3138                #[cfg(test)]
3139                if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
3140                    return Err(io::Error::other(format!(
3141                        "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
3142                        crate::sealed::SEALED_ROOT_FAILURE_PREFIX
3143                    )));
3144                }
3145                crate::sealed::construct_composed_root(spec)
3146                    .map_err(|failure| io::Error::other(spec.describe(failure)))?;
3147            }
3148        }
3149
3150        // Before anything else in this window, on every kernel tier: shut the fd-inheritance
3151        // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
3152        // ruleset installed further down this function — it can do nothing about a descriptor
3153        // the child already holds open at that point, because that fd was opened before the
3154        // ruleset existed and there is no operation left for the ruleset to intercept. Until
3155        // this call existed, the only thing stopping a spawned shell from inheriting an
3156        // arbitrary open fd was the empirical accident that nothing else happened to be open in
3157        // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
3158        // config read, a lock file, a log handle — would have leaked silently into every
3159        // subsequent subprocess, on every platform and every tier, with no test to catch it.
3160        //
3161        // Running it first is also what makes it cover this function's *own* descriptors: the
3162        // namespace-socket socketpair end, the diagnostic pipe and the Landlock grant fds are all used
3163        // below but must never survive the `execve`.
3164        mark_inherited_fds_cloexec()?;
3165
3166        // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
3167        // capability model are independent, both-must-allow gates, so narrowing the Landlock
3168        // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
3169        // must also run *before* `install_seccomp_filter`, because it is what sets
3170        // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
3171        // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
3172        // failure is its own distinct, fail-closed error path before any filter is installed.
3173        drop_all_capabilities()?;
3174
3175        // The child stays *non-dumpable*, inherited from the runtime process
3176        // (`security::harden_process_dumpable`). Nothing reads the child's memory, so nothing
3177        // needs it dumpable. Marking it dumpable opens same-uid `ptrace`, `environ` and core-dump
3178        // access for the whole life of every shell subprocess: do not do so without a reader that
3179        // requires it.
3180        install_seccomp_filter(unix_sockets_allowed)?;
3181
3182        if matches!(
3183            tier,
3184            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3185        ) {
3186            if let Some(fds) = landlock_fds {
3187                apply_landlock_scope(fds).map_err(io::Error::other)?;
3188            }
3189        }
3190
3191        Ok(())
3192    }
3193
3194    /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
3195    /// the only descriptors that survive into the exec'd program are the ones the runtime
3196    /// deliberately set up (stdio) rather than whatever happened to be open.
3197    ///
3198    /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
3199    /// the required semantics here, not a softer variant of it: several fds in this range are
3200    /// still needed *inside* this `pre_exec` window — the socketpair end `create_capsule_netns`
3201    /// sends the namespace's listening sockets over, the diagnostic pipe a failure further down
3202    /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules
3203    /// from. Closing them here would break the very enforcement setup that follows; flagging
3204    /// them means they keep working until `execve`, which then closes them for us.
3205    ///
3206    /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
3207    /// stdio decision and its reasoning.
3208    ///
3209    /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
3210    /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
3211    /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
3212    /// so every shell spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
3213    /// now fails rather than silently running without fd hygiene. That is deliberate and it is
3214    /// this module's established fail-closed discipline (`drop_all_capabilities`,
3215    /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
3216    /// error rather than degrade), but it is a real, user-visible narrowing of the supported
3217    /// kernel range for kernel-enforcement tiers.
3218    pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
3219        // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
3220        // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
3221        // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
3222        // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
3223        // spelling from `close_range(2)`.
3224        let rc = unsafe {
3225            libc::syscall(
3226                libc::SYS_close_range,
3227                super::FD_HYGIENE_FIRST_FD,
3228                libc::c_uint::MAX,
3229                libc::CLOSE_RANGE_CLOEXEC,
3230            )
3231        };
3232        if rc != 0 {
3233            return Err(io::Error::last_os_error());
3234        }
3235        Ok(())
3236    }
3237
3238    /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
3239    /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
3240    /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
3241    /// only capabilities 0–31 and make the kernel log a deprecation warning.
3242    const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
3243
3244    /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
3245    /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
3246    /// capabilities — so this can never under-cover. Numbers the running kernel does not define
3247    /// return `EINVAL`, which the loop skips.
3248    const CAP_LAST_CAP_FALLBACK: u32 = 63;
3249
3250    /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
3251    /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
3252    /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
3253    /// `msghdr`/`cmsghdr` handling.
3254    ///
3255    /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
3256    /// handed to `capset(2)`; Rust itself only ever writes them.
3257    #[repr(C)]
3258    #[allow(dead_code)]
3259    struct CapUserHeader {
3260        version: u32,
3261        pid: libc::c_int,
3262    }
3263
3264    /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
3265    /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
3266    /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
3267    /// note.
3268    #[repr(C)]
3269    #[derive(Clone, Copy)]
3270    #[allow(dead_code)]
3271    struct CapUserData {
3272        effective: u32,
3273        permitted: u32,
3274        inheritable: u32,
3275    }
3276
3277    /// Strips every Linux capability from the forked child before `execve()`, in three steps
3278    /// whose **order matters**:
3279    ///
3280    ///   1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
3281    ///      still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
3282    ///      `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
3283    ///   2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
3284    ///      Shrinking your own permitted set to any subset — including empty — never requires
3285    ///      `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
3286    ///      inheritable also empties the *ambient* set, which the kernel maintains as a subset of
3287    ///      both — that is what covers a non-root capsule handed an unexpected ambient
3288    ///      `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
3289    ///   3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
3290    ///      file-capability binary, and so the seccomp filter installed next can load without
3291    ///      `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
3292    ///      longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
3293    ///      as a side effect.
3294    ///
3295    /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
3296    /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
3297    /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
3298    /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
3299    /// genuinely unprivileged caller, the bounding set would silently survive while this function
3300    /// still reported success.
3301    ///
3302    /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
3303    /// capability transition treats a file's permitted set as all-ones when the process's real
3304    /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
3305    /// would be undone by the very next `execve`; emptying the bounding set is what makes the
3306    /// post-exec permitted (and hence effective) set empty, which is what actually takes
3307    /// `CAP_MKNOD` away from the shell.
3308    ///
3309    /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
3310    /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
3311    /// — and strictly narrows what the subprocess can reach.
3312    fn drop_all_capabilities() -> io::Result<()> {
3313        drop_capability_bounding_set()?;
3314        clear_capability_sets()?;
3315        set_no_new_privs()
3316    }
3317
3318    /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
3319    ///
3320    ///   - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
3321    ///   - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
3322    ///     process that never held the capability being dropped either. Hard-failing here would
3323    ///     break every non-root `mur run`.
3324    ///
3325    /// Any other errno is unexpected and propagates, aborting the exec — this module's
3326    /// fail-closed invariant: no silently-unenforced spawn.
3327    #[allow(unsafe_code)]
3328    fn drop_capability_bounding_set() -> io::Result<()> {
3329        let zero: libc::c_ulong = 0;
3330
3331        for cap in 0..=kernel_last_cap() {
3332            let cap_arg = libc::c_ulong::from(cap);
3333            // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
3334            // ignored arguments — no pointers and no borrowed memory are involved.
3335            let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
3336            if rc == 0 {
3337                continue;
3338            }
3339
3340            let error = io::Error::last_os_error();
3341            match error.raw_os_error() {
3342                Some(libc::EINVAL) | Some(libc::EPERM) => continue,
3343                _ => return Err(error),
3344            }
3345        }
3346
3347        Ok(())
3348    }
3349
3350    /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
3351    /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
3352    /// everything. Any error is therefore unexpected and fails closed.
3353    #[allow(unsafe_code)]
3354    fn clear_capability_sets() -> io::Result<()> {
3355        let header = CapUserHeader {
3356            version: LINUX_CAPABILITY_VERSION_3,
3357            // 0 means "the calling thread" — the only pid `capset` accepts for a capability
3358            // change since Linux 2.6.25.
3359            pid: 0,
3360        };
3361        let data = [CapUserData {
3362            effective: 0,
3363            permitted: 0,
3364            inheritable: 0,
3365        }; 2];
3366
3367        // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
3368        // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
3369        // expect. The kernel only reads through them.
3370        let rc = unsafe {
3371            libc::syscall(
3372                libc::SYS_capset,
3373                &header as *const CapUserHeader,
3374                data.as_ptr(),
3375            )
3376        };
3377        if rc != 0 {
3378            return Err(io::Error::last_os_error());
3379        }
3380
3381        Ok(())
3382    }
3383
3384    /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
3385    /// process's privilege and is never refused for a well-formed call, so any error is
3386    /// unexpected and fails closed. Same call convention and fail-closed style as
3387    /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
3388    /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
3389    /// whole-process hardening.
3390    #[allow(unsafe_code)]
3391    fn set_no_new_privs() -> io::Result<()> {
3392        #[cfg(test)]
3393        if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
3394            return Err(io::Error::other(
3395                "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
3396            ));
3397        }
3398        // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
3399        // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
3400        // upper 32 bits would be unspecified in a variadic call on aarch64.
3401        let enable: libc::c_ulong = 1;
3402        let zero: libc::c_ulong = 0;
3403
3404        // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
3405        // and no borrowed memory are involved.
3406        let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
3407        if rc != 0 {
3408            return Err(io::Error::last_os_error());
3409        }
3410
3411        Ok(())
3412    }
3413
3414    /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
3415    /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
3416    /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
3417    /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
3418    /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
3419    #[allow(unsafe_code)]
3420    fn kernel_last_cap() -> u32 {
3421        const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
3422
3423        // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
3424        // only reads through the pointer.
3425        let fd = unsafe {
3426            libc::open(
3427                PATH.as_ptr() as *const libc::c_char,
3428                libc::O_RDONLY | libc::O_CLOEXEC,
3429            )
3430        };
3431        if fd < 0 {
3432            return CAP_LAST_CAP_FALLBACK;
3433        }
3434
3435        let mut buf = [0u8; 16];
3436        // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
3437        // is passed, so `read` cannot write past it.
3438        let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
3439        // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
3440        unsafe { libc::close(fd) };
3441
3442        if read <= 0 {
3443            return CAP_LAST_CAP_FALLBACK;
3444        }
3445
3446        let parsed = std::str::from_utf8(&buf[..read as usize])
3447            .ok()
3448            .and_then(|text| text.trim().parse::<u32>().ok());
3449
3450        match parsed {
3451            Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
3452            _ => CAP_LAST_CAP_FALLBACK,
3453        }
3454    }
3455
3456    /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
3457    /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
3458    /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
3459    /// does support Landlock, so this is a real combination, not a hypothetical one).
3460    ///
3461    /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
3462    /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
3463    /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
3464    const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
3465        ("landlock_create_ruleset", 444),
3466        ("landlock_add_rule", 445),
3467        ("landlock_restrict_self", 446),
3468    ];
3469
3470    /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
3471    /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
3472    /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
3473    /// held against and the headroom between them are recorded in
3474    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
3475    ///
3476    /// **This filter raises no notifications and needs no supervisor.** Network enforcement lives
3477    /// in [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
3478    /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
3479    /// `Notify` rule may come back.
3480    ///
3481    /// One mechanism plus a default:
3482    ///
3483    ///   - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
3484    ///     a plain integer argument, so the comparison compiles straight into the loaded BPF
3485    ///     program and is evaluated in-kernel at syscall time: no notification is raised and no
3486    ///     memory of another task is read. That is what makes it structurally immune to the
3487    ///     pointer-read TOCTOU class of problem in the first place.
3488    ///   - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
3489    ///     neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
3490    ///     argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
3491    ///     `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
3492    ///     unreachable — they are simply absent, not argument-matched.
3493    fn build_seccomp_filter(
3494        unix_sockets_allowed: bool,
3495    ) -> io::Result<libseccomp::ScmpFilterContext> {
3496        // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
3497        // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
3498        // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
3499        // call's arguments and refused it", `EPERM` means "this syscall is not part of the
3500        // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
3501        // unmediated-surface problem from a policy decision.
3502        let mut filter =
3503            libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
3504                .map_err(to_io_err)?;
3505
3506        // Ask the kernel to record every action this filter takes that is not `Allow` in the
3507        // audit trail, so a denial is attributable after the fact to a syscall number, pid and
3508        // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
3509        // has no channel for anything richer — so this is the only way an operator debugging a
3510        // workload that dies on an unexpected denial gets a syscall name to look at.
3511        //
3512        // Best-effort on purpose, and the one place in this function where an error is swallowed:
3513        // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
3514        // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
3515        // not an enforcement one — the default-deny action and every rule below are unaffected —
3516        // and turning it into a hard failure would take a host with an old libseccomp from
3517        // "denials are logged less legibly" to "no capsule can spawn a shell at all".
3518        //
3519        // Setting the attribute is necessary but not sufficient: the kernel only logs an action
3520        // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
3521        // configuration this process does not control.
3522        // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
3523        // these denials are verified by.
3524        let _ = filter.set_ctl_log(true);
3525
3526        // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
3527        // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
3528        // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
3529        // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
3530        // sees one consistent errno across both argument-level mechanisms.
3531        //
3532        // Untouched by the network-namespace work, and deliberately so: a network namespace does
3533        // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
3534        // still the only thing standing between a capsule and `/var/run/docker.sock`.
3535        //
3536        // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
3537        // adapt the rule to the architectures in the filter — on an arch where `socket` is
3538        // reached through `socketcall(2)` rather than as its own syscall, the exact form would
3539        // fail the whole install instead. Fail-closed is preserved either way, since any error
3540        // here propagates out of `pre_exec` and aborts the spawn.
3541        //
3542        // One rule per domain rather than one rule with three comparators: comparators within a
3543        // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
3544        // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
3545        let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
3546        for domain in super::denied_socket_domains(unix_sockets_allowed) {
3547            filter
3548                .add_rule_conditional(
3549                    libseccomp::ScmpAction::Errno(libc::EACCES),
3550                    socket_syscall,
3551                    &[libseccomp::ScmpArgCompare::new(
3552                        0,
3553                        libseccomp::ScmpCompareOp::Equal,
3554                        domain as u64,
3555                    )],
3556                )
3557                .map_err(to_io_err)?;
3558        }
3559
3560        // The permitted domains need positive rules of their own now that the filter's default is
3561        // a deny — without them `socket()` would be refused for every family, IP included.
3562        //
3563        // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
3564        // libseccomp resolves an unconditional rule for a syscall by *discarding* every
3565        // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
3566        // but the new filter has no chains so the existing chains must be cleared"), so a broad
3567        // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
3568        // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
3569        // default profile carries the same warning against combining a broad `socket` allow with
3570        // explicit errno rules, and works around it the same way — with argument-matched rules
3571        // only. The two domain sets are disjoint by construction (a test pins that), so no
3572        // `socket()` call can match both an allow and a deny rule.
3573        for domain in super::allowed_socket_domains(unix_sockets_allowed) {
3574            filter
3575                .add_rule_conditional(
3576                    libseccomp::ScmpAction::Allow,
3577                    socket_syscall,
3578                    &[libseccomp::ScmpArgCompare::new(
3579                        0,
3580                        libseccomp::ScmpCompareOp::Equal,
3581                        domain as u64,
3582                    )],
3583                )
3584                .map_err(to_io_err)?;
3585        }
3586
3587        // The allowlist proper. Two failure modes are both handled by skipping the name:
3588        //
3589        //   - `from_name` fails when this host's libseccomp does not know the syscall (a name
3590        //     newer than the installed library);
3591        //   - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
3592        //     not exist on this architecture — every legacy x86_64 spelling in the array
3593        //     (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
3594        //
3595        // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
3596        // so the filter can only ever come out *narrower* than intended, never wider. Propagating
3597        // instead would abort every single spawn on the first arch-specific name, which is why
3598        // this is the deliberate exception to the module's otherwise-strict error handling.
3599        for name in super::SECCOMP_SYSCALL_ALLOWLIST {
3600            if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
3601                let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
3602            }
3603        }
3604
3605        // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
3606        // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
3607        // deny would refuse the child's own filesystem scoping and abort every spawn on that
3608        // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
3609        // that installs it, never widen it, and Docker's default profile allows them too.
3610        //
3611        // Resolved by name where possible, with the syscall number as a fallback, because these
3612        // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
3613        // policy above would silently turn an older libseccomp into "no capsule can run a shell
3614        // on a Landlock-capable host". The numbers are the same on every architecture Linux has
3615        // added Landlock to (they were assigned after the syscall-number unification), so the
3616        // fallback is not architecture-specific.
3617        for (name, fallback_nr) in LANDLOCK_SYSCALLS {
3618            let syscall = libseccomp::ScmpSyscall::from_name(name)
3619                .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
3620            filter
3621                .add_rule(libseccomp::ScmpAction::Allow, syscall)
3622                .map_err(to_io_err)?;
3623        }
3624
3625        Ok(filter)
3626    }
3627
3628    /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
3629    /// the filter stays in force for the rest of this task's life and is inherited across the
3630    /// `execve` that follows, which is why nothing outside a forked child ever calls this.
3631    fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
3632        // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
3633        // on Drop) only frees the userspace filter-building context, while the filter itself is
3634        // already installed in the kernel and is inherited across the `execve` that follows.
3635        build_seccomp_filter(unix_sockets_allowed)?
3636            .load()
3637            .map_err(to_io_err)
3638    }
3639
3640    fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
3641        io::Error::other(error.to_string())
3642    }
3643
3644    /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
3645    /// instruction. Dividing an export's length by it is what turns bytes into an instruction
3646    /// count.
3647    #[cfg(test)]
3648    const SOCK_FILTER_BYTES: usize = 8;
3649
3650    /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
3651    /// returns what it wrote.
3652    ///
3653    /// Both exporters take a descriptor and nothing else, so the buffer has to come from
3654    /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
3655    /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
3656    /// crate and so is unavailable to anything outside a test build.
3657    #[cfg(test)]
3658    fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
3659    where
3660        F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
3661    {
3662        use std::io::{Read, Seek, SeekFrom};
3663        use std::os::fd::FromRawFd;
3664
3665        // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
3666        // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
3667        // the descriptor lives and dies inside this function.
3668        let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
3669        if raw < 0 {
3670            return Err(io::Error::last_os_error());
3671        }
3672        // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
3673        // is exactly the precondition for handing it to `OwnedFd`.
3674        let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
3675
3676        export(&memfd).map_err(to_io_err)?;
3677
3678        let mut file = std::fs::File::from(memfd);
3679        file.seek(SeekFrom::Start(0))?;
3680        let mut exported = Vec::new();
3681        file.read_to_end(&mut exported)?;
3682        Ok(exported)
3683    }
3684
3685    /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
3686    /// this host, for the given `unix_sockets_allowed`.
3687    ///
3688    /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
3689    /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
3690    /// divided by that record size. The number depends on the host — how many
3691    /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
3692    /// which is why it is measured here rather than written down.
3693    ///
3694    /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
3695    /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
3696    #[cfg(test)]
3697    fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
3698        let filter = build_seccomp_filter(unix_sockets_allowed)?;
3699        let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
3700        Ok(exported.len() / SOCK_FILTER_BYTES)
3701    }
3702
3703    /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
3704    /// one block per syscall carrying a rule, each block naming the argument comparisons and the
3705    /// action. It is the only view of a built context that shows the rule *shape* rather than the
3706    /// compiled program's size, which is what a test pinning the rule sequence needs.
3707    #[cfg(test)]
3708    fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
3709        let filter = build_seccomp_filter(unix_sockets_allowed)?;
3710        let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
3711        String::from_utf8(exported).map_err(to_io_err)
3712    }
3713
3714    /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
3715    ///
3716    ///   - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
3717    ///     FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
3718    ///     `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
3719    ///     [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
3720    ///     is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
3721    ///     the path it resolved itself, so nothing the capsule writes into its workdir can run;
3722    ///   - on `KernelSealed` only, the workdir-backed `/tmp` store gets that **same** right-set
3723    ///     from the same local binding. `plan_composed_root` binds `<workdir>/.mur-tmp` at `/tmp`
3724    ///     read-write, but Landlock matches an access to `/tmp/x` against the bind's own root inode
3725    ///     rather than the workdir's, so without this rule the mount is writable and every access
3726    ///     to it is nevertheless `EACCES` — bash heredocs, `mktemp`, a compiler's intermediate
3727    ///     objects and a package manager's build scratch all fail. Identical rights are the honest
3728    ///     ones: it is the same storage, counted by the same `workdir_max_bytes` guard, so it must
3729    ///     be neither more nor less runnable than the workdir itself;
3730    ///   - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
3731    ///     shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
3732    ///     `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
3733    ///     [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
3734    ///     grant — never write — with two bits added on top.
3735    ///     Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
3736    ///     exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
3737    ///     file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
3738    ///     whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
3739    ///     — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
3740    ///     (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
3741    ///     `true` everywhere except the two tier-issued sealed grants, which are deliberately
3742    ///     readable (and, on a directory, enumerable) but not runnable;
3743    ///   - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
3744    ///     if its `writable` bit is set. That bit is set for exactly one path — `/dev/null` — which
3745    ///     is therefore the *only* writable path outside the workdir in the whole sandbox. This
3746    ///     list is a compile-time constant, not manifest-derived: no capability declaration widens
3747    ///     or narrows it. Every device path it does not name stays denied by the ordinary
3748    ///     no-matching-rule path below.
3749    ///
3750    /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
3751    /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
3752    /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
3753    /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
3754    /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
3755    /// so a write to any other outside path — including one of these read+execute grant paths — is
3756    /// still denied.
3757    ///
3758    /// `handle_access` must declare the union of every access bit any rule uses, and it stays the
3759    /// **full** ABI v1 set deliberately: a bit declared there but granted by no rule is denied for
3760    /// the whole domain, which is exactly how the rights missing from
3761    /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
3762    /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
3763    /// path, on a host where one of the three is missing — that
3764    /// fails to *open* is skipped
3765    /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
3766    /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
3767    /// preserving the module's fail-closed invariant.
3768    fn apply_landlock_scope(fds: &LandlockChildFds) -> Result<(), String> {
3769        #[cfg(test)]
3770        if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
3771            return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
3772        }
3773
3774        use landlock::{
3775            Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
3776            RulesetCreatedAttr, ABI,
3777        };
3778
3779        let abi = ABI::V1;
3780        let access_all = AccessFs::from_all(abi);
3781        // Enough for the loader to open, read, and map-execute a binary or shared library, and to
3782        // open a file inside a granted directory by exact name — but no write access, and (without
3783        // `ReadDir`) no enumeration of the directory's own entries.
3784        let read_execute = AccessFs::Execute | AccessFs::ReadFile;
3785        // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
3786        let read_execute_list = read_execute | AccessFs::ReadDir;
3787        // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
3788        // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
3789        // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
3790        let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
3791        // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
3792        // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
3793        // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
3794        let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
3795        // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
3796        // not a directory) — only the two data bits, with `WriteFile` reserved for `/dev/null`.
3797        let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
3798        let device_read_write = device_read | AccessFs::WriteFile;
3799
3800        // The one writable right-set in this function, resolved once and used by both rules that
3801        // carry it. The composed root's `/tmp` is a bind of a directory inside the workdir — the
3802        // same storage, under the same size guard — so it gets the same rights, `Execute` axis
3803        // included: `workdir_exec: false` must no more permit running a binary from `/tmp` than
3804        // from the workdir it is stored in. Sharing the binding is what keeps that true; two
3805        // separate calls could drift apart.
3806        let writable_rights = workdir_access_rights(fds.workdir_exec);
3807
3808        // Every fd here was already opened in the parent (before fork). This function performs no
3809        // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
3810        // so a failure here can only be the kernel call itself, not an unrelated path resolution.
3811        let mut ruleset = Ruleset::default()
3812            .set_compatibility(CompatLevel::HardRequirement)
3813            .handle_access(access_all)
3814            .map_err(|error| format!("landlock: handle_access failed: {error}"))?
3815            .create()
3816            .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
3817            .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
3818            .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
3819
3820        // `KernelSealed` only — on every other tier there is no composed root and this is `None`.
3821        // A rule on the workdir alone does not reach the bind mounted over `/tmp`; see
3822        // [`LandlockChildFds::sealed_tmp_fd`].
3823        if let Some(sealed_tmp_fd) = &fds.sealed_tmp_fd {
3824            ruleset = ruleset
3825                .add_rule(PathBeneath::new(sealed_tmp_fd.as_fd(), writable_rights))
3826                .map_err(|error| {
3827                    format!("landlock: add_rule for the workdir-backed /tmp failed: {error}")
3828                })?;
3829        }
3830
3831        // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
3832        // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
3833        // the grant loop's rules for those two paths are taken on the host's files, not on these.
3834        for identity_fd in &fds.sealed_identity_fds {
3835            ruleset = ruleset
3836                .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only))
3837                .map_err(|error| {
3838                    format!("landlock: add_rule for a synthetic /etc file failed: {error}")
3839                })?;
3840        }
3841
3842        for grant in &fds.grants {
3843            // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
3844            // so every fd reaching here is valid. A rule that still fails to add is a genuine
3845            // ruleset-construction failure and propagates as `Err` (fail-closed).
3846            let access = match (grant.executable, grant.list_dir) {
3847                (true, true) => read_execute_list,
3848                (true, false) => read_execute,
3849                (false, true) => read_list,
3850                (false, false) => read_only,
3851            };
3852            ruleset = ruleset
3853                .add_rule(PathBeneath::new(grant.fd.as_fd(), access))
3854                .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
3855        }
3856
3857        for device in &fds.devices {
3858            // Same convention as the grant loop above: a device that failed to open in the parent
3859            // was already dropped, so a failure here is a genuine ruleset-construction failure and
3860            // propagates as `Err` (fail-closed).
3861            let access = if device.writable {
3862                device_read_write
3863            } else {
3864                device_read
3865            };
3866            ruleset = ruleset
3867                .add_rule(PathBeneath::new(device.fd.as_fd(), access))
3868                .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
3869        }
3870
3871        ruleset
3872            .restrict_self()
3873            .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
3874
3875        Ok(())
3876    }
3877
3878    /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
3879    /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
3880    /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
3881    /// now fails here, synchronously, with a message naming the path — before any subprocess is
3882    /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
3883    /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
3884    ///
3885    /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
3886    /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
3887    /// only, never widening.
3888    ///
3889    /// `device_grants` — the tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on
3890    /// `KernelFull` and the composed root's own OCI default set on `KernelSealed`, chosen by
3891    /// `super::landlock_device_grants` — is opened here too, by the same rules: the list is a
3892    /// compile-time constant rather than manifest-derived, and a host where one entry cannot be
3893    /// opened — unusual, but a broken or minimal `/dev` is possible — loses that one device rather
3894    /// than failing the launch. Losing `/dev/null` here degrades to exactly the behavior that
3895    /// existed before this mechanism, which is a strictly narrower scope, never a wider one.
3896    ///
3897    /// `tier` decides one thing only: whether the workdir-backed `/tmp` store
3898    /// ([`crate::sealed::SEALED_TMP_DIR_NAME`]) is created and opened. It is on `KernelSealed`,
3899    /// where `plan_composed_root` unconditionally binds it at `/tmp`, and on no other tier — none
3900    /// of which composes a root at all. Both the creation and the open are fail-closed — an `Err`
3901    /// naming the path, exactly like the workdir's own open above, never the grant loops'
3902    /// shrink-not-fail `continue`. That bind is not manifest-optional, and silently dropping its fd
3903    /// would leave `/tmp` mounted-but-denied, which is precisely the defect the rule exists to fix.
3904    pub(super) fn open_landlock_fds(
3905        accessible_workdir: &Path,
3906        session_workdir: &Path,
3907        landlock_grants: &[LandlockGrant],
3908        device_grants: &[super::CapsuleDeviceGrant],
3909        workdir_exec: bool,
3910        tier: EnforcementTier,
3911    ) -> Result<LandlockChildFds, String> {
3912        let workdir_fd = open_o_path(accessible_workdir)
3913            .map(OwnedFd::from)
3914            .map_err(|error| {
3915                format!(
3916                    "sandbox: failed to open workdir {} for Landlock scoping: {error}",
3917                    accessible_workdir.display()
3918                )
3919            })?;
3920
3921        // `prepare_enforcement` calls this function *before* `build_sealed_root`, so `.mur-tmp`
3922        // need not exist yet; the shared helper creates it (idempotently — `build_sealed_root`
3923        // calls the same one) so there is something here to open.
3924        let sealed_tmp_fd = if tier == EnforcementTier::KernelSealed {
3925            let tmp_store = super::ensure_sealed_tmp_store(session_workdir)?;
3926            let tmp_store_file = open_o_path(&tmp_store).map_err(|error| {
3927                format!(
3928                    "sandbox: failed to open the workdir-backed /tmp store {} for Landlock \
3929                     scoping: {error}",
3930                    tmp_store.display()
3931                )
3932            })?;
3933            Some(OwnedFd::from(tmp_store_file))
3934        } else {
3935            None
3936        };
3937
3938        // Same story, same tier gate, same fail-closed handling: the composed root binds these two
3939        // files at `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of
3940        // those names, whose inodes the capsule never sees. See `LandlockChildFds`.
3941        let sealed_identity_fds = if tier == EnforcementTier::KernelSealed {
3942            let mut fds = Vec::new();
3943            for path in super::ensure_sealed_identity_files(session_workdir)? {
3944                let file = open_o_path(&path).map_err(|error| {
3945                    format!(
3946                        "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
3947                         {error}",
3948                        path.display()
3949                    )
3950                })?;
3951                fds.push(OwnedFd::from(file));
3952            }
3953            fds
3954        } else {
3955            Vec::new()
3956        };
3957
3958        let mut grants = Vec::with_capacity(landlock_grants.len());
3959        for grant in landlock_grants {
3960            match open_o_path(&grant.path) {
3961                Ok(file) => grants.push(OpenLandlockGrant {
3962                    // `ReadDir` is meaningful only on a directory, and the kernel does not merely
3963                    // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
3964                    // directory-only right on a non-directory, and the rule loop in
3965                    // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
3966                    // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
3967                    // `interpreter_runtime` entry, or the static `directory` bit on a
3968                    // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
3969                    // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
3970                    // never add it.
3971                    list_dir: grant.list_dir
3972                        && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
3973                    fd: OwnedFd::from(file),
3974                    executable: grant.executable,
3975                }),
3976                Err(_) => continue,
3977            }
3978        }
3979
3980        let mut devices = Vec::with_capacity(device_grants.len());
3981        for device in device_grants {
3982            match open_o_path(Path::new(device.path)) {
3983                Ok(file) => devices.push(OpenDeviceGrant {
3984                    fd: OwnedFd::from(file),
3985                    writable: device.writable,
3986                }),
3987                Err(_) => continue,
3988            }
3989        }
3990
3991        Ok(LandlockChildFds {
3992            workdir_fd,
3993            workdir_exec,
3994            sealed_tmp_fd,
3995            sealed_identity_fds,
3996            grants,
3997            devices,
3998        })
3999    }
4000
4001    /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
4002    /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
4003    /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
4004    /// closure.
4005    ///
4006    /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
4007    /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
4008    /// not perform a second path lookup to get it.
4009    fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
4010        use std::os::unix::fs::OpenOptionsExt;
4011        std::fs::OpenOptions::new()
4012            .read(true)
4013            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
4014            .open(path)
4015    }
4016
4017    /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
4018    /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
4019    /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
4020    /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
4021    /// returns `Err`, so `execve` is still aborted).
4022    #[allow(unsafe_code)]
4023    pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
4024        let bytes = message.as_bytes();
4025        let len = bytes.len().min(MAX_DIAG_LEN);
4026        let mut written = 0;
4027        while written < len {
4028            // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
4029            // name a live sub-slice of `bytes` for the duration of this single `write` call.
4030            let n = unsafe {
4031                libc::write(
4032                    fd,
4033                    bytes[written..len].as_ptr() as *const libc::c_void,
4034                    len - written,
4035                )
4036            };
4037            if n < 0 {
4038                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
4039                    continue;
4040                }
4041                break;
4042            }
4043            if n == 0 {
4044                break;
4045            }
4046            written += n as usize;
4047        }
4048    }
4049
4050    /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
4051    /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
4052    /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
4053    /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
4054    /// failure that never ran `pre_exec`).
4055    #[allow(unsafe_code)]
4056    pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
4057        let mut buf = Vec::new();
4058        let mut chunk = [0u8; 256];
4059        loop {
4060            // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
4061            // stack buffer of exactly the length passed.
4062            let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
4063            if n < 0 {
4064                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
4065                    continue;
4066                }
4067                break;
4068            }
4069            if n == 0 {
4070                break;
4071            }
4072            buf.extend_from_slice(&chunk[..n as usize]);
4073            if buf.len() >= MAX_DIAG_LEN {
4074                break;
4075            }
4076        }
4077        if buf.is_empty() {
4078            None
4079        } else {
4080            Some(String::from_utf8_lossy(&buf).into_owned())
4081        }
4082    }
4083
4084    /// Spawns the background thread that receives the capsule network namespace's listening
4085    /// sockets from the child and starts serving them through the egress proxy — blocking on that
4086    /// receive, racing concurrently with the caller's (not-yet-issued) `.spawn()` call rather than
4087    /// running after it, per [`SupervisorHandle`]'s doc comment.
4088    ///
4089    /// The child sends exactly one `SCM_RIGHTS` batch over this socketpair, from
4090    /// `create_capsule_netns` — the first thing its `pre_exec` window does. The thread finishes as
4091    /// soon as the proxy is up and hands the live handle back over `proxy_rx` for
4092    /// [`SupervisorHandle::join_best_effort`] to shut down, so the proxy stops when the shell
4093    /// call has finished waiting on the subprocess tree. Both the foreground path and a demoted
4094    /// command's own thread drain the child's pipes to EOF before that shutdown, which is what
4095    /// makes them wait for every descendant holding the pipe.
4096    ///
4097    /// If the sockets never arrive (e.g. `fork()` itself failed, or the child's `pre_exec` closure
4098    /// errored before reaching the send), there is no live child left unserved:
4099    /// `Command::spawn()` surfaces that same underlying failure as its own `Err`, via the
4100    /// close-on-exec error pipe, independently of this thread.
4101    pub(super) fn start_supervisor(
4102        parent_sock: OwnedFd,
4103        egress_policy: crate::egress_proxy::EgressPolicy,
4104        expected_namespace_sockets: usize,
4105        diag_read: OwnedFd,
4106    ) -> SupervisorHandle {
4107        let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
4108        std::thread::spawn(move || {
4109            let proxy = start_namespace_proxy(
4110                parent_sock.as_raw_fd(),
4111                expected_namespace_sockets,
4112                egress_policy,
4113            );
4114            // A closed receiver means the caller gave up waiting (its bounded `recv_timeout`
4115            // elapsed). Dropping the handle here then shuts the proxy down on this thread instead,
4116            // which is the same outcome by a different route — never a proxy left running.
4117            if let Err(returned) = proxy_tx.send(proxy) {
4118                if let Some(proxy) = returned.0 {
4119                    proxy.shutdown();
4120                }
4121            }
4122        });
4123
4124        SupervisorHandle::Linux {
4125            proxy_rx,
4126            diag_read,
4127        }
4128    }
4129
4130    /// Receives the in-namespace listening sockets and starts serving them.
4131    ///
4132    /// Returns `None` when the hand-off failed, which is not a silent degradation: the same
4133    /// `pre_exec` failure that prevented the send also makes the child return `Err`, so
4134    /// `Command::spawn()` reports it and no subprocess runs. There is no path where the capsule
4135    /// starts with a namespace whose proxy never came up — that would be a capsule with no
4136    /// network at all rather than an unbounded one, but it would present as an inexplicable
4137    /// outage, so it is logged here too.
4138    fn start_namespace_proxy(
4139        sock_fd: RawFd,
4140        expected: usize,
4141        policy: crate::egress_proxy::EgressPolicy,
4142    ) -> Option<crate::egress_proxy::EgressProxyHandle> {
4143        let sockets = match crate::network_namespace::receive_namespace_sockets(sock_fd, expected) {
4144            Ok(sockets) => sockets,
4145            Err(error) => {
4146                crate::runtime_err!(
4147                    "[capsule-runtime] warning: failed to receive the capsule's network \
4148                     namespace sockets: {error} (the subprocess spawn reports the underlying \
4149                     pre_exec failure independently)"
4150                );
4151                return None;
4152            }
4153        };
4154        match crate::egress_proxy::start_egress_proxy(sockets, policy) {
4155            Ok(proxy) => Some(proxy),
4156            Err(error) => {
4157                crate::runtime_err!(
4158                    "[capsule-runtime] warning: failed to serve the capsule's network namespace \
4159                     sockets: {error} (the subprocess tree has no route off this host, so its \
4160                     network calls fail closed)"
4161                );
4162                None
4163            }
4164        }
4165    }
4166
4167    /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
4168    /// against — all four numbers produced by running code on the host under test, never quoted.
4169    ///
4170    /// The written record these tests feed is
4171    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4172    ///
4173    /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
4174    /// irreversible for the life of the task, so an in-process load would silently apply to the
4175    /// rest of the test binary and to everything it spawns. Every load happens in a forked child
4176    /// that does nothing else and then `_exit`s.
4177    #[cfg(test)]
4178    #[allow(clippy::print_stdout, clippy::print_stderr)]
4179    mod seccomp_budget {
4180        use std::io;
4181        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
4182
4183        // `seccomp_filter_instruction_count` is absent from this list because the test that
4184        // reports it carries the same name; it is called as `super::` throughout.
4185        use super::{
4186            build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
4187            LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
4188        };
4189        use crate::sandbox::{
4190            allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
4191            EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
4192            SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
4193        };
4194
4195        /// The kernel charges each filter already attached to a task four instructions on top of
4196        /// its own length when it decides whether the next attach fits under
4197        /// [`SECCOMP_MAX_INSNS_PER_PATH`].
4198        ///
4199        /// It is only part of the arithmetic. The length the kernel adds up is that of the
4200        /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
4201        /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
4202        /// out slightly longer. So a load count predicted from the exported classic length is an
4203        /// upper bound on the load count the kernel actually allows, never an equality, which is
4204        /// why the cumulative probe brackets it rather than pinning it.
4205        const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
4206
4207        /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
4208        /// Every real errno is a small positive number, and 0 is reserved for a load that
4209        /// succeeded, so this cannot collide with either.
4210        const PROBE_ERRNO_UNKNOWN: i32 = 254;
4211
4212        /// A filter built only to be measured or refused: default `Allow`, and `rules`
4213        /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
4214        ///
4215        /// Default `Allow` rather than the real filter's default deny, for two reasons that both
4216        /// matter. A child has to survive its own load long enough to `_exit` and, in the
4217        /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
4218        /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
4219        /// equals the default action, so the rules have to carry an action the default does not:
4220        /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
4221        /// every rule in the program.
4222        ///
4223        /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
4224        /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
4225        /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
4226        fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
4227            let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
4228                .map_err(to_io_err)?;
4229            filter.set_api_sysrawrc(true).map_err(to_io_err)?;
4230
4231            let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
4232            for value in 0..rules {
4233                filter
4234                    .add_rule_conditional(
4235                        libseccomp::ScmpAction::Errno(libc::EPERM),
4236                        syscall,
4237                        &[libseccomp::ScmpArgCompare::new(
4238                            0,
4239                            libseccomp::ScmpCompareOp::Equal,
4240                            value as u64,
4241                        )],
4242                    )
4243                    .map_err(to_io_err)?;
4244            }
4245
4246            let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
4247            Ok((filter, exported.len() / SOCK_FILTER_BYTES))
4248        }
4249
4250        /// The smallest probe-filter rule count whose compiled program is longer than
4251        /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
4252        /// monotonically with the rule count, so both halves are sound and the whole search
4253        /// costs a couple of dozen exports rather than thousands.
4254        fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
4255            let mut under = 1;
4256            let mut over = 8;
4257            while probe_filter(over)?.1 <= instructions {
4258                under = over;
4259                over *= 2;
4260                assert!(
4261                    over <= 1 << 20,
4262                    "no probe filter of up to {over} rules exceeded {instructions} instructions — \
4263                     the probe is not growing the program the way this search assumes"
4264                );
4265            }
4266            while over - under > 1 {
4267                let mid = under + (over - under) / 2;
4268                if probe_filter(mid)?.1 <= instructions {
4269                    under = mid;
4270                } else {
4271                    over = mid;
4272                }
4273            }
4274            Ok(over)
4275        }
4276
4277        /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
4278        /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
4279        /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
4280        /// only ever sees a plain `libc` value.
4281        fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
4282            use libseccomp::error::SeccompErrno;
4283
4284            if let Some(raw) = error.sysrawrc() {
4285                return -raw;
4286            }
4287            match error.errno() {
4288                Some(SeccompErrno::EACCES) => libc::EACCES,
4289                Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
4290                Some(SeccompErrno::EINVAL) => libc::EINVAL,
4291                Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
4292                _ => PROBE_ERRNO_UNKNOWN,
4293            }
4294        }
4295
4296        /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
4297        /// that took, `Err(errno)` for one the kernel refused.
4298        ///
4299        /// The filter is built by the caller, before the fork, so the child's whole post-fork
4300        /// window is `load()` and `_exit`.
4301        fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
4302            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
4303            // child confines itself to work that does not depend on another thread's state. This
4304            // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
4305            // the BPF program on every load and does not cache the one `export_bpf` produced —
4306            // so this probe is not async-signal-safe in the strict sense; it is bounded to a
4307            // child that touches nothing else and dies immediately.
4308            let pid = unsafe { libc::fork() };
4309            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
4310
4311            if pid == 0 {
4312                let code = match filter.load() {
4313                    Ok(()) => 0,
4314                    Err(error) => refusal_errno(&error),
4315                };
4316                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4317                // which is what keeps libtest's state in the parent untouched.
4318                unsafe { libc::_exit(code) }
4319            }
4320
4321            let mut status: libc::c_int = 0;
4322            // SAFETY: `pid` is the child just forked; `status` is a live local.
4323            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
4324            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
4325            assert!(
4326                libc::WIFEXITED(status),
4327                "the load probe child did not exit normally (status {status})"
4328            );
4329            match libc::WEXITSTATUS(status) {
4330                0 => Ok(()),
4331                errno => Err(errno),
4332            }
4333        }
4334
4335        /// The number of distinct syscalls the allowlist actually contributes a rule for on this
4336        /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
4337        /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
4338        /// is an independent measurement rather than a restatement of the filter under test.
4339        fn resolvable_allowlist_syscalls() -> io::Result<usize> {
4340            use std::collections::HashSet;
4341
4342            let mut scratch =
4343                libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4344                    .map_err(to_io_err)?;
4345            let mut accepted = HashSet::new();
4346            for name in SECCOMP_SYSCALL_ALLOWLIST {
4347                if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
4348                    if scratch
4349                        .add_rule(libseccomp::ScmpAction::Allow, syscall)
4350                        .is_ok()
4351                    {
4352                        accepted.insert(i32::from(syscall));
4353                    }
4354                }
4355            }
4356            Ok(accepted.len())
4357        }
4358
4359        /// The line both ceiling probes print instead of running, on a host where no seccomp
4360        /// filter can be loaded at all.
4361        fn skip_without_seccomp(what: &str) -> bool {
4362            let tier = detect_enforcement_tier();
4363            if tier == EnforcementTier::EnvironmentOnly {
4364                eprintln!(
4365                    "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
4366                     can load a seccomp filter at all, which is every tier above \
4367                     EnforcementTier::EnvironmentOnly; detected {tier:?}"
4368                );
4369                return true;
4370            }
4371            false
4372        }
4373
4374        /// What the real filter compiles to, against the budget this project holds itself to and
4375        /// the ceiling the kernel holds it to.
4376        ///
4377        /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
4378        /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
4379        /// cover the larger of the two.
4380        #[test]
4381        fn seccomp_filter_instruction_count() {
4382            for unix_sockets_allowed in [false, true] {
4383                let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
4384                    .expect("export the built seccomp filter");
4385
4386                eprintln!(
4387                    "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
4388                     -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
4389                     per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
4390                     cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
4391                    BPF_MAXINSNS_CEILING as f64 / count as f64,
4392                    SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
4393                );
4394
4395                assert!(
4396                    count > 0,
4397                    "the exported filter is empty, so nothing was measured — \
4398                     `export_bpf` wrote no `struct sock_filter` records at all"
4399                );
4400                assert!(
4401                    count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
4402                    "the child's seccomp filter compiles to {count} instructions with \
4403                     unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
4404                     {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
4405                     {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
4406                     still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
4407                     deliberately — updating \
4408                     docs/content/reference/seccomp-filter-instruction-budget.md with the new \
4409                     numbers — rather than letting the filter grow into a spawn failure"
4410                );
4411            }
4412        }
4413
4414        /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
4415        /// pseudo-filter-code export so a change to the shape is a test failure rather than a
4416        /// silent change of policy.
4417        ///
4418        /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
4419        /// anywhere in this function would make libseccomp discard every argument-conditional
4420        /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
4421        /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
4422        #[test]
4423        fn seccomp_filter_rule_shape() {
4424            let allowlist_syscalls =
4425                resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
4426            assert!(
4427                allowlist_syscalls > 0,
4428                "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
4429                 for the wrong reason"
4430            );
4431
4432            for unix_sockets_allowed in [false, true] {
4433                let filter =
4434                    build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
4435                assert_eq!(
4436                    filter.get_act_default().expect("read the default action"),
4437                    libseccomp::ScmpAction::Errno(libc::EPERM),
4438                    "the filter's default action is no longer a deny with EPERM, which is what \
4439                     makes every syscall outside the allowlist unreachable"
4440                );
4441
4442                let pfc =
4443                    seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
4444                let denied = denied_socket_domains(unix_sockets_allowed);
4445                let allowed = allowed_socket_domains(unix_sockets_allowed);
4446
4447                // One block per syscall carrying at least one rule: `socket`, every allowlist
4448                // name libseccomp accepted, and the three Landlock syscalls.
4449                let syscall_blocks = pfc.matches("# filter for syscall ").count();
4450                assert_eq!(
4451                    syscall_blocks,
4452                    1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
4453                    "the built filter carries rules for {syscall_blocks} syscalls, not the \
4454                     socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
4455                     syscalls this code adds",
4456                    LANDLOCK_SYSCALLS.len()
4457                );
4458
4459                let deny_actions = pfc
4460                    .matches(&format!("action ERRNO({});", libc::EACCES))
4461                    .count();
4462                assert_eq!(
4463                    deny_actions,
4464                    denied.len(),
4465                    "the built filter carries {deny_actions} EACCES rules, not one per denied \
4466                     socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
4467                     exactly like this, and would have discarded every domain denial with it"
4468                );
4469
4470                // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
4471                // contributes one per allowed domain.
4472                let allow_actions = pfc.matches("action ALLOW;").count();
4473                assert_eq!(
4474                    allow_actions,
4475                    allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
4476                    "the built filter carries {allow_actions} Allow rules, not one per allowed \
4477                     socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
4478                     Landlock syscall"
4479                );
4480
4481                for (name, _) in LANDLOCK_SYSCALLS {
4482                    assert!(
4483                        pfc.contains(&format!("\"{name}\"")),
4484                        "{name} has no rule in the built filter, so `apply_landlock_scope` would \
4485                         be refused by the filter installed just before it"
4486                    );
4487                }
4488
4489                eprintln!(
4490                    "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
4491                     default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
4492                     {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
4493                    denied.len(),
4494                    allowed.len(),
4495                    LANDLOCK_SYSCALLS.len(),
4496                );
4497            }
4498        }
4499
4500        /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
4501        /// two filters that straddle it and observing that the kernel takes one and refuses the
4502        /// other with `EINVAL`.
4503        ///
4504        /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
4505        /// ceiling, whatever the filter's length.
4506        #[test]
4507        fn seccomp_per_filter_ceiling() {
4508            if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
4509                return;
4510            }
4511
4512            let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
4513                .expect("bracket the per-filter ceiling");
4514            let (under_filter, under_count) =
4515                probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
4516            let (over_filter, over_count) =
4517                probe_filter(over_rules).expect("build the over-ceiling probe filter");
4518
4519            assert!(
4520                under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
4521                "the two probe filters ({under_count} and {over_count} instructions) do not \
4522                 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
4523                 nothing about where the ceiling is"
4524            );
4525
4526            let under_outcome = load_in_child(&under_filter);
4527            let over_outcome = load_in_child(&over_filter);
4528
4529            eprintln!(
4530                "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
4531                 instructions; smallest that did not = {over_count} instructions, refused with \
4532                 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
4533                over_outcome.err().unwrap_or(0),
4534                libc::EINVAL,
4535            );
4536
4537            assert_eq!(
4538                under_outcome,
4539                Ok(()),
4540                "a {under_count}-instruction filter was refused, below the \
4541                 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
4542            );
4543            assert_eq!(
4544                over_outcome,
4545                Err(libc::EINVAL),
4546                "a {over_count}-instruction filter was not refused with EINVAL, so \
4547                 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
4548                 per-filter limit"
4549            );
4550        }
4551
4552        /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
4553        /// task carries, by stacking filters in a forked child until the kernel refuses.
4554        ///
4555        /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
4556        /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
4557        /// filter of a few hundred instructions, and that had not yet reached `load()` when it
4558        /// failed, did not come from the kernel's seccomp code.
4559        #[test]
4560        fn seccomp_cumulative_ceiling() {
4561            if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
4562                return;
4563            }
4564
4565            // A probe of roughly the shipped filter's size, so the number of stacked loads reads
4566            // directly as "how many filters like ours one task could carry".
4567            let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
4568            let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
4569            // Enough headroom past the ceiling that a kernel which never refuses is caught by the
4570            // cap rather than by the test hanging.
4571            let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
4572
4573            let mut fds = [0 as libc::c_int; 2];
4574            // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
4575            let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
4576            assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
4577            // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
4578            let (read_fd, write_fd) =
4579                unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
4580
4581            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
4582            // child confines itself to work that does not depend on another thread's state. This
4583            // child calls `load()` in a loop, writes eight bytes and `_exit`s.
4584            let pid = unsafe { libc::fork() };
4585            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
4586
4587            if pid == 0 {
4588                let mut loads: u32 = 0;
4589                let mut refusal: i32 = 0;
4590                while loads < max_loads as u32 {
4591                    match filter.load() {
4592                        Ok(()) => loads += 1,
4593                        Err(error) => {
4594                            refusal = refusal_errno(&error);
4595                            break;
4596                        }
4597                    }
4598                }
4599
4600                let mut report = [0u8; 8];
4601                report[..4].copy_from_slice(&loads.to_ne_bytes());
4602                report[4..].copy_from_slice(&refusal.to_ne_bytes());
4603                // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
4604                // local of exactly the length passed, and the descriptor is the pipe's write end.
4605                unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
4606                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
4607                unsafe { libc::_exit(0) }
4608            }
4609
4610            drop(write_fd);
4611            let mut report = [0u8; 8];
4612            std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
4613                .expect("read the cumulative probe child's report");
4614            let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
4615            let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
4616
4617            let mut status: libc::c_int = 0;
4618            // SAFETY: `pid` is the child just forked; `status` is a live local.
4619            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
4620            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
4621
4622            let stacked = loads * count;
4623            let cumulative_at_refusal = count + loads * per_filter_cost;
4624            let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
4625            let shipped = super::seccomp_filter_instruction_count(false)
4626                .expect("measure the shipped filter for comparison");
4627
4628            eprintln!(
4629                "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
4630                 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
4631                 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
4632                 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
4633                 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
4634                 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
4635                 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
4636                 instructions, so reaching this ceiling would take {} of them in a single task.",
4637                loads + 1,
4638                libc::ENOMEM,
4639                SECCOMP_MAX_INSNS_PER_PATH / shipped,
4640            );
4641
4642            assert!(
4643                loads >= 2,
4644                "the kernel refused the filter chain after only {loads} load(s), so this is not \
4645                 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
4646            );
4647            assert_eq!(
4648                refusal,
4649                libc::ENOMEM,
4650                "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
4651                 {refusal}, not ENOMEM ({}) — the rule-out in \
4652                 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
4653                 being what this ceiling returns",
4654                libc::ENOMEM
4655            );
4656            assert!(
4657                loads <= loads_if_counted_classic,
4658                "the kernel took {loads} loads of a {count}-instruction filter — more than the \
4659                 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
4660                 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
4661                 each one, so this kernel's ceiling is higher than the recorded value"
4662            );
4663            // Loose on purpose: the exported classic length under-counts what the kernel adds
4664            // up, so the bound below tolerates any plausible conversion expansion while still
4665            // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
4666            // say, BPF_MAXINSNS would fail here.
4667            assert!(
4668                loads * 2 > loads_if_counted_classic,
4669                "the kernel refused after {loads} loads of a {count}-instruction filter, far \
4670                 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
4671                 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
4672                 one recorded"
4673            );
4674            assert!(
4675                shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
4676                "the shipped filter ({shipped} instructions) is within a factor of two of the \
4677                 cumulative ceiling, which would make the rule-out this test supports untrue"
4678            );
4679        }
4680    }
4681}
4682
4683#[cfg(test)]
4684mod tests {
4685    use super::*;
4686    use murmur_artifact::security_warnings::W_SEC_003;
4687
4688    /// Content check only: this asserts the *constant*, not the security property. Spawning a
4689    /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
4690    /// enforcement, so it would read as evidence while proving nothing.
4691    ///
4692    /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
4693    /// breaks every subprocess invocation on both spawn paths.
4694    #[test]
4695    fn fd_hygiene_range_starts_above_stdio() {
4696        assert_eq!(
4697            FD_HYGIENE_FIRST_FD, 3,
4698            "fd hygiene must start at 3 — 0/1/2 are the stdio pipes both spawn paths need to \
4699             survive execve"
4700        );
4701    }
4702
4703    /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
4704    /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
4705    /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
4706    /// runs without fd hygiene.
4707    ///
4708    /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
4709    /// argument validation before it touches any descriptor — so this exercises the errno path
4710    /// without closing or flagging a single fd of the test process. A kernel older than 5.11
4711    /// rejects the production call with a different errno and the identical convention — see the
4712    /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
4713    #[cfg(target_os = "linux")]
4714    #[allow(unsafe_code)]
4715    #[test]
4716    fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
4717        // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
4718        // bit is set here, so the kernel rejects the call on argument validation and no descriptor
4719        // of this process is closed or marked close-on-exec.
4720        let rc = unsafe {
4721            libc::syscall(
4722                libc::SYS_close_range,
4723                FD_HYGIENE_FIRST_FD,
4724                libc::c_uint::MAX,
4725                libc::c_uint::MAX,
4726            )
4727        };
4728
4729        assert_eq!(rc, -1, "an invalid flag word must not be accepted");
4730        assert_eq!(
4731            std::io::Error::last_os_error().raw_os_error(),
4732            Some(libc::EINVAL),
4733            "the two statements the production path runs on failure must carry the kernel's errno"
4734        );
4735    }
4736
4737    /// A host that can back the sealed mechanism: AppArmor out of the way, and a namespace probe
4738    /// that really created one.
4739    fn sealed_capable() -> crate::sealed::SealedProbe {
4740        crate::sealed::SealedProbe {
4741            userns_grant: crate::sealed::UsernsGrant::ProfileConfining,
4742            namespace: crate::sealed::NamespaceProbe::Ok,
4743        }
4744    }
4745
4746    /// A host that cannot: the default, which claims nothing.
4747    fn sealed_incapable() -> crate::sealed::SealedProbe {
4748        crate::sealed::SealedProbe::default()
4749    }
4750
4751    /// A probe that learned nothing: no Landlock answer and a sealed half that claims nothing.
4752    /// The reading a host gives when neither half of the probe could run.
4753    fn unprobed() -> HostProbe {
4754        HostProbe::from_parts(None, crate::sealed::SealedProbe::default())
4755    }
4756
4757    /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
4758    /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
4759    /// tier is decided from a reading.
4760    #[test]
4761    fn an_unprobed_host_resolves_to_seccomp_only() {
4762        let tier = unprobed().tier();
4763        if cfg!(target_os = "linux") {
4764            assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
4765        } else {
4766            assert_eq!(tier, EnforcementTier::EnvironmentOnly);
4767        }
4768        assert_ne!(tier, EnforcementTier::KernelSealed);
4769        assert_ne!(tier, EnforcementTier::KernelFull);
4770    }
4771
4772    /// A reading that demonstrated nothing must still name what is missing: a refusal with no
4773    /// blocker is a refusal an operator cannot act on.
4774    #[test]
4775    fn an_unprobed_host_names_a_blocker() {
4776        assert!(unprobed().sealed_blocker().is_some());
4777    }
4778
4779    /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
4780    /// so nothing downstream can read a failed probe as a met floor.
4781    #[test]
4782    fn an_unprobed_host_achieves_less_than_sealed() {
4783        let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
4784        assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
4785    }
4786
4787    /// The whole point of the direction: a capsule declaring `sealed` against a reading that
4788    /// demonstrated nothing is refused, with the shortfall naming the mechanism.
4789    #[test]
4790    fn an_unprobed_host_refuses_a_declared_sealed_floor() {
4791        let probe = unprobed();
4792        let achieved = crate::containment::achieved_class_for_tier(probe.tier());
4793        let error = crate::containment::check_containment_floor(
4794            murmur_artifact::ContainmentClass::Sealed,
4795            achieved,
4796            probe.sealed_blocker(),
4797            false,
4798        )
4799        .expect_err("an unprobed host cannot back a declared sealed floor");
4800        assert!(matches!(
4801            error,
4802            crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
4803        ));
4804    }
4805
4806    /// The tier decision reads only `UsernsGrant::permits_userns()`, so every grant that permits
4807    /// the namespace has to reach the same tier — otherwise reporting provenance would have
4808    /// changed an outcome, which is exactly what naming the grant set out not to do.
4809    #[test]
4810    fn tier_from_probe_is_identical_across_every_permitting_userns_grant() {
4811        use crate::sealed::{NamespaceProbe, SealedProbe, UsernsGrant};
4812
4813        for grant in UsernsGrant::ALL {
4814            let probe = SealedProbe {
4815                userns_grant: *grant,
4816                namespace: NamespaceProbe::Ok,
4817            };
4818            let expected = if grant.permits_userns() {
4819                EnforcementTier::KernelSealed
4820            } else {
4821                EnforcementTier::KernelFull
4822            };
4823            assert_eq!(
4824                tier_from_probe(true, Some(true), probe),
4825                expected,
4826                "grant {grant:?} must decide the tier only through permits_userns()"
4827            );
4828        }
4829    }
4830
4831    #[test]
4832    fn tier_from_probe_non_linux_is_always_environment_only() {
4833        for landlock in [None, Some(true), Some(false)] {
4834            for sealed in [sealed_capable(), sealed_incapable()] {
4835                assert_eq!(
4836                    tier_from_probe(false, landlock, sealed),
4837                    EnforcementTier::EnvironmentOnly
4838                );
4839            }
4840        }
4841    }
4842
4843    #[test]
4844    fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
4845        assert_eq!(
4846            tier_from_probe(true, Some(true), sealed_incapable()),
4847            EnforcementTier::KernelFull
4848        );
4849    }
4850
4851    #[test]
4852    fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
4853        assert_eq!(
4854            tier_from_probe(true, Some(false), sealed_incapable()),
4855            EnforcementTier::KernelSeccompOnly
4856        );
4857    }
4858
4859    #[test]
4860    fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
4861        assert_eq!(
4862            tier_from_probe(true, None, sealed_incapable()),
4863            EnforcementTier::KernelSeccompOnly
4864        );
4865    }
4866
4867    /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a usable
4868    /// Landlock ABI, AppArmor out of the way, and a namespace probe that succeeded — and drops to
4869    /// exactly the tier the missing element still supports, never further.
4870    #[test]
4871    fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
4872        use crate::sealed::{NamespaceProbe, SealedProbe};
4873
4874        assert_eq!(
4875            tier_from_probe(true, Some(true), sealed_capable()),
4876            EnforcementTier::KernelSealed
4877        );
4878
4879        // No AppArmor profile (and the restriction is on) → the mechanism is unavailable, but
4880        // Landlock still is: `scoped`, not `advisory`.
4881        assert_eq!(
4882            tier_from_probe(
4883                true,
4884                Some(true),
4885                SealedProbe {
4886                    userns_grant: crate::sealed::UsernsGrant::Withheld,
4887                    namespace: NamespaceProbe::Ok
4888                }
4889            ),
4890            EnforcementTier::KernelFull
4891        );
4892
4893        // The container case, and its three other namespace failure modes.
4894        for namespace in [
4895            NamespaceProbe::Denied,
4896            NamespaceProbe::MapDenied,
4897            NamespaceProbe::MountDenied,
4898            NamespaceProbe::Unsupported,
4899        ] {
4900            assert_eq!(
4901                tier_from_probe(
4902                    true,
4903                    Some(true),
4904                    SealedProbe {
4905                        userns_grant: crate::sealed::UsernsGrant::ProfileConfining,
4906                        namespace
4907                    }
4908                ),
4909                EnforcementTier::KernelFull,
4910                "namespace probe {namespace:?} must fall back to KernelFull, never below it"
4911            );
4912        }
4913
4914        // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
4915        // cannot reach sealed no matter how good its namespaces are.
4916        assert_eq!(
4917            tier_from_probe(true, Some(false), sealed_capable()),
4918            EnforcementTier::KernelSeccompOnly
4919        );
4920        assert_eq!(
4921            tier_from_probe(true, None, sealed_capable()),
4922            EnforcementTier::KernelSeccompOnly
4923        );
4924
4925        // Not Linux at all.
4926        assert_eq!(
4927            tier_from_probe(false, Some(true), sealed_capable()),
4928            EnforcementTier::EnvironmentOnly
4929        );
4930    }
4931
4932    /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
4933    /// could give it more. Silently installing a composed root under a `scoped` declaration would
4934    /// delete host paths its `interpreter_runtime` grants legitimately name.
4935    #[test]
4936    fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
4937        use murmur_artifact::ContainmentClass;
4938
4939        assert_eq!(
4940            applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
4941            EnforcementTier::KernelSealed
4942        );
4943        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
4944            assert_eq!(
4945                applied_tier(EnforcementTier::KernelSealed, declared),
4946                EnforcementTier::KernelFull,
4947                "declaring {declared} must not opt a capsule into the composed root"
4948            );
4949        }
4950
4951        // Every other tier is passed through untouched, whatever was declared — the declared floor
4952        // can only ever weaken the applied mechanism, never strengthen it past the host.
4953        for host_tier in [
4954            EnforcementTier::KernelFull,
4955            EnforcementTier::KernelSeccompOnly,
4956            EnforcementTier::EnvironmentOnly,
4957        ] {
4958            for declared in [
4959                ContainmentClass::Advisory,
4960                ContainmentClass::Scoped,
4961                ContainmentClass::Sealed,
4962            ] {
4963                assert_eq!(applied_tier(host_tier, declared), host_tier);
4964            }
4965        }
4966    }
4967
4968    #[test]
4969    fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
4970        use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
4971
4972        let policy = CapabilityPolicy {
4973            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
4974                binary: "python3".to_string(),
4975                dirs: vec![
4976                    // Already inside /usr, which the fixed list binds wholesale.
4977                    InterpreterRuntimeDir {
4978                        path: "/usr/lib/python3.11".to_string(),
4979                        list_dir: true,
4980                    },
4981                    InterpreterRuntimeDir {
4982                        path: "/opt/py/lib".to_string(),
4983                        list_dir: true,
4984                    },
4985                    // A duplicate, and a relative path that names nothing absolute.
4986                    InterpreterRuntimeDir {
4987                        path: "/opt/py/lib".to_string(),
4988                        list_dir: false,
4989                    },
4990                    InterpreterRuntimeDir {
4991                        path: "relative/lib".to_string(),
4992                        list_dir: false,
4993                    },
4994                ],
4995            }],
4996            ..CapabilityPolicy::default()
4997        };
4998        let exec_allow = vec![
4999            PathBuf::from("/usr/bin/python3"),
5000            PathBuf::from("/opt/toolchain/bin/cc"),
5001        ];
5002
5003        assert_eq!(
5004            resolve_sealed_bind_dirs(&exec_allow, &policy),
5005            vec![
5006                PathBuf::from("/opt/toolchain/bin"),
5007                PathBuf::from("/opt/py/lib")
5008            ]
5009        );
5010    }
5011
5012    /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
5013    /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
5014    /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
5015    /// relative path — which cannot be re-based under the composed root at all — is skipped.
5016    #[test]
5017    fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
5018        use murmur_artifact::StagedRuntimeGrant;
5019
5020        let grant = |binary: &str, source: &str| StagedRuntimeGrant {
5021            binary: binary.to_string(),
5022            source_path: source.to_string(),
5023            pin: "pin-1".to_string(),
5024        };
5025        let policy = CapabilityPolicy {
5026            shell_staged_runtime: vec![
5027                grant("python3", "/opt/py"),
5028                // Inside /usr, which the fixed list already binds. Kept anyway — unlike
5029                // `resolve_sealed_bind_dirs`, which drops exactly this case.
5030                grant("perl", "/usr/lib/perl5"),
5031                // A duplicate of the first, and a relative path naming nothing absolute.
5032                grant("python3.12", "/opt/py"),
5033                grant("ruby", "relative/ruby"),
5034            ],
5035            ..CapabilityPolicy::default()
5036        };
5037
5038        assert_eq!(
5039            resolve_staged_runtime_dirs(&policy),
5040            vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
5041        );
5042    }
5043
5044    /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
5045    /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
5046    /// must therefore cover the same directories.
5047    #[test]
5048    fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
5049        use murmur_artifact::StagedRuntimeGrant;
5050
5051        let policy = CapabilityPolicy {
5052            shell_staged_runtime: vec![StagedRuntimeGrant {
5053                binary: "python3".to_string(),
5054                source_path: "/opt/py".to_string(),
5055                pin: "pin-1".to_string(),
5056            }],
5057            ..CapabilityPolicy::default()
5058        };
5059
5060        let dirs = resolve_staged_runtime_dirs(&policy);
5061        let grants = resolve_staged_runtime_landlock_grants(&policy);
5062
5063        assert_eq!(
5064            grants,
5065            vec![LandlockGrant {
5066                path: PathBuf::from("/opt/py"),
5067                list_dir: true,
5068                executable: true,
5069            }],
5070            "a staged tree is walked by the runtime that uses it, so it must be listable",
5071        );
5072        assert_eq!(
5073            grants
5074                .iter()
5075                .map(|grant| grant.path.clone())
5076                .collect::<Vec<_>>(),
5077            dirs,
5078            "the bound set and the granted set must not drift apart",
5079        );
5080    }
5081
5082    #[test]
5083    fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
5084        let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
5085
5086        assert_eq!(
5087            grants,
5088            crate::sealed::SEALED_RUNTIME_PATHS
5089                .iter()
5090                .map(|path| LandlockGrant {
5091                    path: PathBuf::from(path),
5092                    list_dir: true,
5093                    executable: false,
5094                })
5095                .collect::<Vec<_>>(),
5096            "the composed root binds exactly these, so exactly these must be enumerable — \
5097             the bound set and the granted set must not drift apart",
5098        );
5099        assert!(
5100            grants.iter().all(|grant| grant.list_dir),
5101            "a runtime tree an interpreter walks is useless without getdents64",
5102        );
5103        assert!(
5104            grants.iter().all(|grant| !grant.executable),
5105            "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
5106             sealed capsule run every binary the host ships, whatever shell.allow says",
5107        );
5108    }
5109
5110    #[test]
5111    fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
5112        for tier in [
5113            EnforcementTier::KernelFull,
5114            EnforcementTier::KernelSeccompOnly,
5115            EnforcementTier::EnvironmentOnly,
5116        ] {
5117            assert!(
5118                resolve_sealed_runtime_landlock_grants(tier).is_empty(),
5119                "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
5120                 would enumerate the real host filesystem",
5121            );
5122        }
5123    }
5124
5125    /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
5126    /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
5127    /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
5128    /// is the only honest thing a test can do about a kernel capability it may not have.
5129    #[test]
5130    fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
5131        let policy = CapabilityPolicy::default();
5132        let usr = PathBuf::from("/usr");
5133
5134        let scoped = ShellEnforcement::resolve(
5135            &policy,
5136            murmur_artifact::ContainmentClass::Scoped,
5137            HostProbe::probe(),
5138        )
5139        .expect("an empty policy resolves");
5140        assert!(
5141            !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
5142            "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
5143             unenumerable there",
5144        );
5145
5146        let sealed = ShellEnforcement::resolve(
5147            &policy,
5148            murmur_artifact::ContainmentClass::Sealed,
5149            HostProbe::probe(),
5150        )
5151        .expect("an empty policy resolves");
5152        let granted: Vec<&LandlockGrant> = sealed
5153            .landlock_grants
5154            .iter()
5155            .filter(|grant| {
5156                crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
5157            })
5158            .collect();
5159        if sealed.tier == EnforcementTier::KernelSealed {
5160            assert_eq!(
5161                granted.len(),
5162                crate::sealed::SEALED_RUNTIME_PATHS.len(),
5163                "a sealed session must carry one listable grant per bound runtime path",
5164            );
5165            assert!(granted
5166                .iter()
5167                .all(|grant| grant.list_dir && !grant.executable));
5168        } else {
5169            assert!(
5170                granted.is_empty(),
5171                "this host fell back to {:?}, where there is no composed root to enumerate",
5172                sealed.tier,
5173            );
5174        }
5175    }
5176
5177    #[test]
5178    fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
5179        let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
5180
5181        assert_eq!(
5182            grants,
5183            crate::sealed::SEALED_ETC_PATHS
5184                .iter()
5185                .map(|entry| LandlockGrant {
5186                    path: PathBuf::from(entry.path),
5187                    list_dir: entry.directory,
5188                    executable: false,
5189                })
5190                .collect::<Vec<_>>(),
5191            "the composed root binds exactly these /etc entries, so exactly these must be \
5192             readable — the bound set and the granted set must not drift apart",
5193        );
5194        assert!(
5195            grants.iter().all(|grant| !grant.executable),
5196            "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
5197             run — /etc/alternatives is a directory of symlinks into /usr/bin",
5198        );
5199
5200        // The `directory` classification itself, spelled out independently of the constant so a
5201        // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
5202        // regular file) shows up as a test failure rather than as a TLS bug on a real host.
5203        let listable: Vec<&str> = grants
5204            .iter()
5205            .filter(|grant| grant.list_dir)
5206            .map(|grant| grant.path.to_str().unwrap())
5207            .collect();
5208        assert_eq!(
5209            listable,
5210            vec![
5211                "/etc/ld.so.conf.d",
5212                "/etc/alternatives",
5213                "/etc/ssl",
5214                "/etc/pki",
5215                "/etc/ca-certificates",
5216                "/etc/terminfo",
5217            ],
5218            "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
5219             the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
5220        );
5221    }
5222
5223    #[test]
5224    fn sealed_etc_landlock_grants_are_sealed_tier_only() {
5225        for tier in [
5226            EnforcementTier::KernelFull,
5227            EnforcementTier::KernelSeccompOnly,
5228            EnforcementTier::EnvironmentOnly,
5229        ] {
5230            assert!(
5231                resolve_sealed_etc_landlock_grants(tier).is_empty(),
5232                "{tier:?} has no composed root, so these rules would hand out the real host's \
5233                 /etc — its trust store, resolv.conf and account databases",
5234            );
5235        }
5236    }
5237
5238    /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
5239    /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
5240    /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
5241    /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
5242    #[test]
5243    fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
5244        let policy = CapabilityPolicy::default();
5245        let is_etc_grant = |grant: &LandlockGrant| {
5246            crate::sealed::SEALED_ETC_PATHS
5247                .iter()
5248                .any(|entry| Path::new(entry.path) == grant.path)
5249        };
5250
5251        let scoped = ShellEnforcement::resolve(
5252            &policy,
5253            murmur_artifact::ContainmentClass::Scoped,
5254            HostProbe::probe(),
5255        )
5256        .expect("an empty policy resolves");
5257        assert!(
5258            !scoped.landlock_grants.iter().any(is_etc_grant),
5259            "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
5260             neighbours must not become readable there",
5261        );
5262
5263        let sealed = ShellEnforcement::resolve(
5264            &policy,
5265            murmur_artifact::ContainmentClass::Sealed,
5266            HostProbe::probe(),
5267        )
5268        .expect("an empty policy resolves");
5269        let granted: Vec<&LandlockGrant> = sealed
5270            .landlock_grants
5271            .iter()
5272            .filter(|grant| is_etc_grant(grant))
5273            .collect();
5274        if sealed.tier == EnforcementTier::KernelSealed {
5275            assert_eq!(
5276                granted.len(),
5277                crate::sealed::SEALED_ETC_PATHS.len(),
5278                "a sealed session must carry one read grant per bound /etc entry",
5279            );
5280            assert!(granted.iter().all(|grant| !grant.executable));
5281        } else {
5282            assert!(
5283                granted.is_empty(),
5284                "this host fell back to {:?}, where there is no composed root and /etc is the \
5285                 host's own",
5286                sealed.tier,
5287            );
5288        }
5289    }
5290
5291    #[test]
5292    fn resolve_network_allowlist_ips_empty_list_is_empty() {
5293        let ips = resolve_network_allowlist_ips(&[]).unwrap();
5294        assert!(ips.is_empty());
5295    }
5296
5297    #[test]
5298    fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
5299        let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
5300        assert!(
5301            !ips.is_empty(),
5302            "localhost should resolve to at least one address"
5303        );
5304        assert!(
5305            ips.iter().all(|ip| ip.is_loopback()),
5306            "every resolved localhost address should be loopback, got {ips:?}"
5307        );
5308    }
5309
5310    #[test]
5311    fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
5312        let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
5313        assert!(!error.is_empty());
5314    }
5315
5316    /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
5317    /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
5318    /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
5319    /// to resolve, which keeps this test independent of the host's DNS.
5320    #[test]
5321    fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
5322        let ips =
5323            resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
5324                .expect("an unresolvable host must not be a hard error");
5325        assert!(
5326            ips.is_empty(),
5327            "an unresolvable host should contribute no IPs, got {ips:?}"
5328        );
5329    }
5330
5331    /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
5332    /// loop continues rather than aborting on the first miss.
5333    #[test]
5334    fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
5335        let ips = resolve_network_allowlist_ips(&[
5336            "definitely-not-a-real-host.invalid".to_string(),
5337            "localhost".to_string(),
5338        ])
5339        .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
5340
5341        assert!(
5342            !ips.is_empty(),
5343            "localhost's addresses should survive alongside an unresolvable host"
5344        );
5345        assert!(
5346            ips.iter().all(|ip| ip.is_loopback()),
5347            "only localhost should have contributed addresses, got {ips:?}"
5348        );
5349    }
5350
5351    #[test]
5352    fn resolve_network_allowlist_ips_dedupes_across_hosts() {
5353        // Two entries that both resolve to loopback should not produce duplicate IPs beyond
5354        // what's actually distinct.
5355        let ips =
5356            resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
5357                .unwrap();
5358        let mut sorted = ips.clone();
5359        sorted.sort();
5360        sorted.dedup();
5361        assert_eq!(
5362            ips.len(),
5363            sorted.len(),
5364            "resolved IPs should already be deduplicated"
5365        );
5366    }
5367
5368    #[test]
5369    fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
5370        let enforcement = ShellEnforcement::environment_only();
5371        assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
5372        assert!(enforcement.network_allow_ips.is_empty());
5373    }
5374
5375    /// With no scope a spawn's `RLIMIT_NPROC` is headroom over a real count. A `0` baseline
5376    /// would turn `max_processes` into an absolute per-uid ceiling that an interactive account
5377    /// is already past, failing the subprocess's first `fork()`.
5378    #[cfg(any(target_os = "linux", target_os = "macos"))]
5379    #[test]
5380    fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
5381        match ShellEnforcement::environment_only().nproc_bound() {
5382            crate::resources::NprocBound::Headroom { baseline } => assert!(
5383                baseline > 0,
5384                "the test process is owned by this uid, so the count cannot be zero"
5385            ),
5386            other => panic!("no scope must give headroom, got {other:?}"),
5387        }
5388    }
5389
5390    /// The baseline is taken on each call, not when the enforcement was built: threads the host
5391    /// starts in between are counted by the next spawn.
5392    #[cfg(target_os = "linux")]
5393    #[test]
5394    fn nproc_bound_without_a_scope_is_measured_on_each_call() {
5395        let _lock = crate::resources::test_support::host_threads_lock();
5396        let enforcement = ShellEnforcement::environment_only();
5397        let baseline = |bound| match bound {
5398            crate::resources::NprocBound::Headroom { baseline } => baseline,
5399            other => panic!("no scope must give headroom, got {other:?}"),
5400        };
5401
5402        let first = baseline(enforcement.nproc_bound());
5403        let holders = crate::resources::test_support::HeldThreads::start(200);
5404        let second = baseline(enforcement.nproc_bound());
5405        drop(holders);
5406
5407        assert!(
5408            second > first,
5409            "200 threads started between the calls must raise the baseline: {first} then {second}"
5410        );
5411    }
5412
5413    /// A spawn that will join a live scope leaves `RLIMIT_NPROC` to `pids.max`.
5414    #[cfg(target_os = "linux")]
5415    #[test]
5416    fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
5417        if !crate::cgroup::cgroup_delegation_available() {
5418            crate::runtime_err!(
5419                "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
5420                 cannot delegate a cgroup v2 scope"
5421            );
5422            return;
5423        }
5424        let temp = tempfile::tempdir().unwrap();
5425        let limits = crate::resources::HostResourceLimits::default();
5426        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
5427            .expect("this host delegates a cgroup scope")
5428            .scope;
5429        let enforcement = ShellEnforcement::environment_only().with_host_bounding(scope, None);
5430
5431        assert_eq!(
5432            enforcement.nproc_bound(),
5433            crate::resources::NprocBound::CgroupPidsMax
5434        );
5435    }
5436
5437    /// A Linux capsule with a cgroup scope gets no `RLIMIT_NPROC` from the runtime, so threads the
5438    /// rest of the host starts after the enforcement was resolved cannot spend its fork headroom.
5439    /// The 400 holders put the uid well past any `RLIMIT_NPROC` derived from a count taken before
5440    /// they started, which fails the first `fork()` in the subprocess with `EAGAIN`. The 40
5441    /// concurrent `sleep`s and the concurrent `node` children each need headroom well past the
5442    /// declared `max_processes` of 32.
5443    #[cfg(target_os = "linux")]
5444    #[test]
5445    fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
5446        const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
5447        if !crate::cgroup::cgroup_delegation_available() {
5448            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
5449            return;
5450        }
5451        let _lock = crate::resources::test_support::host_threads_lock();
5452
5453        let temp = tempfile::tempdir().unwrap();
5454        let limits = crate::resources::HostResourceLimits {
5455            max_processes: 32,
5456            max_open_files: 256,
5457            ..crate::resources::HostResourceLimits::default()
5458        };
5459        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
5460            .expect("this host delegates a cgroup scope")
5461            .scope;
5462        assert!(
5463            scope.is_some(),
5464            "a required scope on Linux is always created"
5465        );
5466        let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
5467        let mut shell_allow = vec!["bash".to_string()];
5468        if node_available {
5469            shell_allow.push("node".to_string());
5470        }
5471        let policy = CapabilityPolicy {
5472            shell_allow,
5473            resources: limits,
5474            ..CapabilityPolicy::default()
5475        };
5476        let mut enforcement = ShellEnforcement::environment_only();
5477        enforcement.resource_limits = limits;
5478        let enforcement = enforcement.with_host_bounding(scope, None);
5479
5480        let holders = crate::resources::test_support::HeldThreads::start(400);
5481
5482        let result = crate::shell::execute_shell(
5483            "bash",
5484            &[
5485                "-c",
5486                "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
5487            ],
5488            &[],
5489            temp.path(),
5490            temp.path(),
5491            &policy,
5492            &enforcement,
5493        )
5494        .expect("bash must run");
5495        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
5496        assert_eq!(result.resource_limit_hit, None);
5497        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
5498        let lines: Vec<&str> = result.stdout.lines().collect();
5499        assert_eq!(
5500            lines,
5501            [
5502                crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
5503                "256"
5504            ],
5505            "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
5506        );
5507
5508        if node_available {
5509            let script = "const { spawn } = require('child_process');\n\
5510                const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
5511                  const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
5512                  child.once('error', (error) => resolve(error.message));\n\
5513                  child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
5514                }));\n\
5515                Promise.all(runs).then((outcomes) => {\n\
5516                  const failures = outcomes.filter((outcome) => outcome !== null);\n\
5517                  if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
5518                });\n";
5519            let result = crate::shell::execute_shell(
5520                "node",
5521                &["-e", script],
5522                &[],
5523                temp.path(),
5524                temp.path(),
5525                &policy,
5526                &enforcement,
5527            )
5528            .expect("node must run");
5529            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
5530        } else {
5531            crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
5532        }
5533
5534        drop(holders);
5535    }
5536
5537    /// Without a scope `RLIMIT_NPROC` is the only process bound, so its baseline has to follow the
5538    /// host: 300 threads started after the enforcement was resolved raise the next spawn's
5539    /// ceiling, and releasing them lowers the one after.
5540    #[cfg(target_os = "linux")]
5541    #[test]
5542    fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
5543        const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
5544        let _lock = crate::resources::test_support::host_threads_lock();
5545
5546        let temp = tempfile::tempdir().unwrap();
5547        let policy = CapabilityPolicy {
5548            shell_allow: vec!["bash".to_string()],
5549            resources: crate::resources::HostResourceLimits {
5550                max_processes: 64,
5551                ..crate::resources::HostResourceLimits::default()
5552            },
5553            ..CapabilityPolicy::default()
5554        };
5555        let mut enforcement = ShellEnforcement::environment_only();
5556        enforcement.resource_limits = policy.resources;
5557
5558        let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
5559        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
5560        if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
5561            crate::runtime_err!(
5562                "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
5563                 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
5564            );
5565            return;
5566        }
5567
5568        let hard_nproc = || -> u64 {
5569            let result = crate::shell::execute_shell(
5570                "bash",
5571                &["-c", "ulimit -Hu"],
5572                &[],
5573                temp.path(),
5574                temp.path(),
5575                &policy,
5576                &enforcement,
5577            )
5578            .expect("bash must run");
5579            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
5580            result.stdout.trim().parse().expect("a finite ceiling")
5581        };
5582
5583        let holders = crate::resources::test_support::HeldThreads::start(300);
5584        let held = hard_nproc();
5585        assert!(
5586            held >= before + 64 + 150,
5587            "the ceiling must count the holder threads started after the enforcement was \
5588             resolved: {held} with {before} tasks before them"
5589        );
5590
5591        drop(holders);
5592        let released = hard_nproc();
5593        assert!(
5594            released < held,
5595            "the ceiling must drop once the holders exit: {released} after, {held} while held"
5596        );
5597    }
5598
5599    fn make_executable(path: &Path, contents: &str) {
5600        use std::os::unix::fs::PermissionsExt;
5601        std::fs::write(path, contents).unwrap();
5602        let mut perms = std::fs::metadata(path).unwrap().permissions();
5603        perms.set_mode(0o755);
5604        std::fs::set_permissions(path, perms).unwrap();
5605    }
5606
5607    fn bootstrap_log_contents(workdir: &Path) -> String {
5608        std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
5609    }
5610
5611    // ---- exec allowlist resolution (identity, not names) ----
5612
5613    #[test]
5614    fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
5615        let temp = tempfile::tempdir().unwrap();
5616        let bin_dir = temp.path().join("bin");
5617        std::fs::create_dir(&bin_dir).unwrap();
5618        let tool = bin_dir.join("mytool");
5619        make_executable(&tool, "#!/bin/sh\nexit 0\n");
5620
5621        let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
5622        assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
5623    }
5624
5625    #[test]
5626    fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
5627        let temp = tempfile::tempdir().unwrap();
5628        let resolved = resolve_exec_allowlist_in(
5629            &["definitely-not-a-real-binary".to_string(), String::new()],
5630            &[temp.path().to_path_buf()],
5631        );
5632        assert!(
5633            resolved.is_empty(),
5634            "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
5635        );
5636    }
5637
5638    #[test]
5639    fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
5640        let temp = tempfile::tempdir().unwrap();
5641        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
5642        let resolved =
5643            resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
5644        assert!(resolved.is_empty());
5645    }
5646
5647    #[test]
5648    fn resolve_exec_allowlist_canonicalizes_path_entries_through_symlinks() {
5649        let temp = tempfile::tempdir().unwrap();
5650        let real = temp.path().join("real-tool");
5651        make_executable(&real, "#!/bin/sh\nexit 0\n");
5652        let link = temp.path().join("link-tool");
5653        std::os::unix::fs::symlink(&real, &link).unwrap();
5654
5655        let resolved = resolve_exec_allowlist_in(&[link.to_string_lossy().into_owned()], &[]);
5656        assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
5657    }
5658
5659    // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
5660
5661    #[test]
5662    fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
5663        let temp = tempfile::tempdir().unwrap();
5664        let bin_dir = temp.path().join("bin");
5665        std::fs::create_dir(&bin_dir).unwrap();
5666        let tool = bin_dir.join("mytool");
5667        make_executable(&tool, "#!/bin/sh\nexit 0\n");
5668
5669        let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
5670        assert_eq!(
5671            resolved,
5672            std::fs::canonicalize(&tool)
5673                .unwrap()
5674                .to_string_lossy()
5675                .into_owned(),
5676            "a resolvable name must report the canonical absolute path, not the bare name"
5677        );
5678    }
5679
5680    #[test]
5681    fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
5682        let temp = tempfile::tempdir().unwrap();
5683        let path_dirs = [temp.path().to_path_buf()];
5684        let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
5685        assert_eq!(
5686            resolved, "definitely-not-a-real-binary",
5687            "an unresolvable name must fall back to the bare name, never error"
5688        );
5689        assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
5690    }
5691
5692    #[test]
5693    fn resolve_invoked_binary_path_takes_the_first_path_match() {
5694        let temp = tempfile::tempdir().unwrap();
5695        let first = temp.path().join("first");
5696        let second = temp.path().join("second");
5697        std::fs::create_dir(&first).unwrap();
5698        std::fs::create_dir(&second).unwrap();
5699        make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
5700        make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
5701
5702        let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
5703        assert_eq!(
5704            resolved,
5705            std::fs::canonicalize(first.join("mytool"))
5706                .unwrap()
5707                .to_string_lossy()
5708                .into_owned(),
5709            "reporting must match execvp: the first PATH hit is the one that ran"
5710        );
5711    }
5712
5713    #[test]
5714    fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
5715        let temp = tempfile::tempdir().unwrap();
5716        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
5717        assert_eq!(
5718            resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
5719            "mytool"
5720        );
5721    }
5722
5723    #[test]
5724    fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
5725        let temp = tempfile::tempdir().unwrap();
5726        let real = temp.path().join("real-tool");
5727        make_executable(&real, "#!/bin/sh\nexit 0\n");
5728        let link = temp.path().join("link-tool");
5729        std::os::unix::fs::symlink(&real, &link).unwrap();
5730
5731        assert_eq!(
5732            resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
5733            std::fs::canonicalize(&real)
5734                .unwrap()
5735                .to_string_lossy()
5736                .into_owned()
5737        );
5738    }
5739
5740    // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
5741
5742    /// Builds a minimal but well-formed ELF64 image exercising exactly the fields
5743    /// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
5744    /// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
5745    /// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
5746    fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
5747        let has_dynamic = !needed.is_empty() || runpath.is_some();
5748
5749        let mut phnum: u16 = 1; // PT_LOAD
5750        if interp.is_some() {
5751            phnum += 1;
5752        }
5753        if has_dynamic {
5754            phnum += 1;
5755        }
5756
5757        let ehsize = 64usize;
5758        let phentsize = 56usize;
5759        let phoff = ehsize;
5760        let body_start = phoff + phentsize * phnum as usize;
5761
5762        // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
5763        let mut strtab: Vec<u8> = vec![0];
5764        let mut needed_offsets = Vec::new();
5765        for name in needed {
5766            needed_offsets.push(strtab.len() as u64);
5767            strtab.extend_from_slice(name.as_bytes());
5768            strtab.push(0);
5769        }
5770        let runpath_offset = runpath.map(|r| {
5771            let off = strtab.len() as u64;
5772            strtab.extend_from_slice(r.as_bytes());
5773            strtab.push(0);
5774            off
5775        });
5776
5777        let mut body: Vec<u8> = Vec::new();
5778
5779        let interp_offset = body_start + body.len();
5780        if let Some(i) = interp {
5781            body.extend_from_slice(i.as_bytes());
5782            body.push(0);
5783        }
5784
5785        // Align the dynamic array to 8 bytes.
5786        while !(body_start + body.len()).is_multiple_of(8) {
5787            body.push(0);
5788        }
5789        let dyn_offset = body_start + body.len();
5790        let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
5791        let dyn_size = num_dyn * 16;
5792        let strtab_offset = dyn_offset + dyn_size;
5793
5794        if has_dynamic {
5795            let mut push_dyn = |tag: u64, val: u64| {
5796                body.extend_from_slice(&tag.to_le_bytes());
5797                body.extend_from_slice(&val.to_le_bytes());
5798            };
5799            for &o in &needed_offsets {
5800                push_dyn(1, o); // DT_NEEDED
5801            }
5802            if let Some(ro) = runpath_offset {
5803                push_dyn(29, ro); // DT_RUNPATH
5804            }
5805            push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
5806            push_dyn(10, strtab.len() as u64); // DT_STRSZ
5807            push_dyn(0, 0); // DT_NULL
5808            assert_eq!(body_start + body.len(), strtab_offset);
5809            body.extend_from_slice(&strtab);
5810        }
5811
5812        let total_len = body_start + body.len();
5813        let mut out = vec![0u8; total_len];
5814        out[0..4].copy_from_slice(b"\x7fELF");
5815        out[4] = 2; // ELFCLASS64
5816        out[5] = 1; // little-endian
5817        out[6] = 1; // EI_VERSION
5818        out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
5819        out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
5820        out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
5821        out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
5822        out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
5823        out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
5824
5825        let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
5826        phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
5827        if let Some(i) = interp {
5828            phdrs.push((
5829                3,
5830                interp_offset as u64,
5831                interp_offset as u64,
5832                i.len() as u64 + 1,
5833            ));
5834        }
5835        if has_dynamic {
5836            phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
5837        }
5838        for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
5839            let base = phoff + idx * phentsize;
5840            out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
5841            out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
5842            out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
5843            out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
5844        }
5845
5846        out[body_start..total_len].copy_from_slice(&body);
5847        out
5848    }
5849
5850    #[test]
5851    fn parse_elf_extracts_interp_needed_and_runpath() {
5852        let bytes = build_elf64(
5853            Some("/lib64/ld-linux-x86-64.so.2"),
5854            &["libc.so.6", "libm.so.6"],
5855            Some("$ORIGIN/../lib:/opt/custom/lib"),
5856        );
5857        let deps = parse_elf_dependencies(&bytes).expect("well-formed ELF64 must parse");
5858        assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
5859        assert_eq!(
5860            deps.needed,
5861            vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
5862        );
5863        assert_eq!(
5864            deps.runpaths,
5865            vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
5866            "DT_RUNPATH must be split on ':' with empty parts dropped"
5867        );
5868    }
5869
5870    #[test]
5871    fn parse_elf_static_binary_contributes_only_itself() {
5872        // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
5873        let bytes = build_elf64(None, &[], None);
5874        let deps = parse_elf_dependencies(&bytes).expect("a static ELF64 is still valid ELF");
5875        assert_eq!(deps, ElfDependencies::default());
5876    }
5877
5878    #[test]
5879    fn parse_elf_rejects_non_elf_and_non_elf64() {
5880        assert!(parse_elf_dependencies(b"not an elf at all, way too short").is_none());
5881        // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
5882        let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
5883        elf32[4] = 1;
5884        assert!(
5885            parse_elf_dependencies(&elf32).is_none(),
5886            "32-bit ELF is not a binary this runtime execs and must not parse"
5887        );
5888    }
5889
5890    // ---- soname resolution (loader search-order, synthetic fixtures) ----
5891
5892    #[test]
5893    fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
5894        let temp = tempfile::tempdir().unwrap();
5895        let origin = temp.path().join("app");
5896        std::fs::create_dir(&origin).unwrap();
5897        let libs = temp.path().join("libs");
5898        std::fs::create_dir(&libs).unwrap();
5899        std::fs::write(libs.join("libc.so.6"), "x").unwrap();
5900
5901        // Found in a standard search directory.
5902        assert_eq!(
5903            resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
5904            Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
5905        );
5906        // Unresolvable soname → None (shrink-not-fail).
5907        assert_eq!(
5908            resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
5909            None
5910        );
5911
5912        // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
5913        let origin_lib = origin.join("lib");
5914        std::fs::create_dir(&origin_lib).unwrap();
5915        std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
5916        assert_eq!(
5917            resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
5918            Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
5919        );
5920    }
5921
5922    // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
5923
5924    #[test]
5925    fn resolve_landlock_grants_derives_interp_and_needed_closure() {
5926        let temp = tempfile::tempdir().unwrap();
5927        let bin_dir = temp.path().join("bin");
5928        let lib_dir = temp.path().join("lib");
5929        std::fs::create_dir(&bin_dir).unwrap();
5930        std::fs::create_dir(&lib_dir).unwrap();
5931
5932        // A fake interpreter and a fake shared library, both outside any "workdir".
5933        let interp = bin_dir.join("ld-fake.so");
5934        std::fs::write(&interp, "not really elf").unwrap();
5935        std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
5936
5937        // A synthetic dynamically-linked binary naming that interp + that soname.
5938        let prog = bin_dir.join("prog");
5939        let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
5940        std::fs::write(&prog, &elf).unwrap();
5941
5942        let grants =
5943            resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
5944
5945        let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
5946        assert!(
5947            grants.contains(&expect(prog)),
5948            "the binary itself must be granted: {grants:?}"
5949        );
5950        assert!(
5951            grants.contains(&expect(interp)),
5952            "the ELF interpreter must be granted: {grants:?}"
5953        );
5954        assert!(
5955            grants.contains(&expect(lib_dir.join("libfake.so.1"))),
5956            "each resolved DT_NEEDED library must be granted: {grants:?}"
5957        );
5958    }
5959
5960    #[test]
5961    fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
5962        let temp = tempfile::tempdir().unwrap();
5963        let bin_dir = temp.path().join("bin");
5964        std::fs::create_dir(&bin_dir).unwrap();
5965
5966        // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
5967        let prog = bin_dir.join("prog");
5968        std::fs::write(
5969            &prog,
5970            build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
5971        )
5972        .unwrap();
5973        let missing = bin_dir.join("does-not-exist");
5974
5975        let grants =
5976            resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
5977
5978        // Only the real binary survives; the missing binary, missing interp, and unresolvable
5979        // soname each contribute nothing (shrink-not-fail).
5980        assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
5981    }
5982
5983    // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
5984
5985    fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
5986        InterpreterRuntimeGrant {
5987            binary: binary.to_string(),
5988            dirs: dirs
5989                .iter()
5990                .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
5991                    path: path.to_string(),
5992                    list_dir: *list_dir,
5993                })
5994                .collect(),
5995        }
5996    }
5997
5998    #[test]
5999    fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
6000        // Two directories under the same binary, opposite `list_dir` — proving enumerability is
6001        // per-directory and copied exactly, not inferred or unified.
6002        let grants = resolve_interpreter_runtime_grants(&[interp_grant(
6003            "python3",
6004            &[
6005                ("/usr/lib/python3.11", true),
6006                ("/usr/lib/python3.11/lib-dynload", false),
6007            ],
6008        )]);
6009
6010        assert_eq!(
6011            grants,
6012            vec![
6013                LandlockGrant {
6014                    path: PathBuf::from("/usr/lib/python3.11"),
6015                    list_dir: true,
6016                    executable: true,
6017                },
6018                LandlockGrant {
6019                    path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
6020                    list_dir: false,
6021                    executable: true,
6022                },
6023            ]
6024        );
6025    }
6026
6027    #[test]
6028    fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
6029        // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
6030        // scope` skips any that fail to open), and multiple grants flatten in order.
6031        let grants = resolve_interpreter_runtime_grants(&[
6032            interp_grant("python3", &[("/opt/py/nonexistent", false)]),
6033            interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
6034        ]);
6035
6036        assert_eq!(
6037            grants,
6038            vec![
6039                LandlockGrant {
6040                    path: PathBuf::from("/opt/py/nonexistent"),
6041                    list_dir: false,
6042                    executable: true,
6043                },
6044                LandlockGrant {
6045                    path: PathBuf::from("/opt/rb/also-nonexistent"),
6046                    list_dir: true,
6047                    executable: true,
6048                },
6049            ]
6050        );
6051        assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
6052    }
6053
6054    #[test]
6055    fn non_listable_files_marks_every_closure_file_non_enumerable() {
6056        // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
6057        // (ReadDir on a regular file is a no-op anyway).
6058        let wrapped = LandlockGrant::non_listable_files(vec![
6059            PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
6060            PathBuf::from("/usr/bin/bash"),
6061        ]);
6062        assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
6063        assert_eq!(
6064            wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
6065            vec![
6066                PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
6067                PathBuf::from("/usr/bin/bash"),
6068            ]
6069        );
6070    }
6071
6072    // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
6073    //
6074    // The canonical statement of what the *content* checks in this file are worth, referenced by
6075    // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
6076    // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
6077    // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
6078    // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
6079    // buys one thing: changing a constant cannot happen by accident.
6080    //
6081    // The enforcement claim for this constant is verified only by the manual procedure in
6082    // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
6083    // uncontainerized Linux hardware.
6084
6085    #[test]
6086    fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
6087        let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
6088            .iter()
6089            .map(|device| (device.path, device.writable))
6090            .collect();
6091        assert_eq!(
6092            listed,
6093            vec![
6094                ("/dev/null", true),
6095                ("/dev/zero", false),
6096                ("/dev/urandom", false),
6097            ],
6098            "the capsule device set is fixed at exactly these three paths and these access \
6099             levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
6100             and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
6101             both. Widening this list is allowed only on demonstrated workload failure, and the \
6102             failure gets recorded alongside the widening (see the manual acceptance procedure on \
6103             the diagnostics reference page)."
6104        );
6105    }
6106
6107    #[test]
6108    fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
6109        // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
6110        // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
6111        // shell child has dropped). It is excluded because nothing needs a blocking RNG when
6112        // /dev/urandom is granted — not because writing to it would poison the kernel pool.
6113        for excluded in [
6114            "/dev/random",
6115            "/dev/full",
6116            "/dev/tty",
6117            "/dev/console",
6118            "/dev/mem",
6119            "/dev/sda",
6120            "/dev",
6121        ] {
6122            assert!(
6123                !CAPSULE_DEVICE_GRANTS
6124                    .iter()
6125                    .any(|device| device.path == excluded),
6126                "{excluded} must not be in the capsule device set — it is denied by the ordinary \
6127                 no-matching-rule path, and adding it here would be the only way to change that"
6128            );
6129        }
6130    }
6131
6132    #[test]
6133    fn capsule_device_grants_name_absolute_paths_under_dev() {
6134        // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
6135        // against whatever cwd the host process happens to have.
6136        for device in CAPSULE_DEVICE_GRANTS {
6137            assert!(
6138                device.path.starts_with("/dev/"),
6139                "{} must be an absolute path under /dev",
6140                device.path
6141            );
6142        }
6143    }
6144
6145    // ---- `denied_socket_domains` ----------------------------------------------------------
6146    //
6147    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
6148    // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
6149    // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
6150    // real Linux hardware.
6151
6152    #[test]
6153    fn denied_socket_domains_denies_unix_by_default() {
6154        let denied = denied_socket_domains(false);
6155        assert!(
6156            denied.contains(&LINUX_AF_UNIX),
6157            "AF_UNIX must be denied when the manifest does not declare \
6158             capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
6159        );
6160    }
6161
6162    #[test]
6163    fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
6164        let denied = denied_socket_domains(true);
6165        assert!(
6166            !denied.contains(&LINUX_AF_UNIX),
6167            "a capsule that declared capabilities.network.unix_sockets: true must get no \
6168             AF_UNIX deny rule at all"
6169        );
6170    }
6171
6172    /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
6173    /// identical for both values of the one flag that exists.
6174    #[test]
6175    fn denied_socket_domains_always_denies_netlink_and_packet() {
6176        for unix_sockets_allowed in [false, true] {
6177            let denied = denied_socket_domains(unix_sockets_allowed);
6178            assert!(
6179                denied.contains(&LINUX_AF_NETLINK),
6180                "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
6181                 {unix_sockets_allowed})"
6182            );
6183            assert!(
6184                denied.contains(&LINUX_AF_PACKET),
6185                "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
6186                 {unix_sockets_allowed})"
6187            );
6188        }
6189    }
6190
6191    /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
6192    /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
6193    /// deny.
6194    #[test]
6195    fn denied_socket_domains_never_denies_ip_families() {
6196        for unix_sockets_allowed in [false, true] {
6197            let denied = denied_socket_domains(unix_sockets_allowed);
6198            assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
6199            assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
6200        }
6201    }
6202
6203    /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
6204    /// an unnoticed side effect of touching the function.
6205    #[test]
6206    fn denied_socket_domains_returns_exactly_the_documented_sets() {
6207        assert_eq!(
6208            denied_socket_domains(false),
6209            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
6210        );
6211        assert_eq!(
6212            denied_socket_domains(true),
6213            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
6214        );
6215    }
6216
6217    /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
6218    /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
6219    /// they really do agree with `libc`; on macOS this test compiles away, because
6220    /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
6221    /// constants are literals.
6222    #[cfg(target_os = "linux")]
6223    #[test]
6224    fn linux_socket_domain_constants_match_libc() {
6225        assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
6226        assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
6227        assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
6228        assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
6229        assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
6230    }
6231
6232    /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
6233    /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
6234    /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
6235    /// anything stated here.
6236    #[test]
6237    fn socket_domain_allow_and_deny_sets_never_overlap() {
6238        for unix_sockets_allowed in [false, true] {
6239            let denied = denied_socket_domains(unix_sockets_allowed);
6240            for domain in allowed_socket_domains(unix_sockets_allowed) {
6241                assert!(
6242                    !denied.contains(&domain),
6243                    "socket domain {domain} is both allowed and denied \
6244                     (unix_sockets_allowed = {unix_sockets_allowed})"
6245                );
6246            }
6247        }
6248    }
6249
6250    /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
6251    /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
6252    /// default-deny filter would silently keep unix sockets refused even after a capsule declared
6253    /// the capability.
6254    #[test]
6255    fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
6256        assert_eq!(
6257            allowed_socket_domains(false),
6258            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
6259        );
6260        assert_eq!(
6261            allowed_socket_domains(true),
6262            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
6263        );
6264    }
6265
6266    // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
6267    //
6268    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
6269    // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
6270    // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
6271    // the allowlist against a newer upstream profile.
6272
6273    #[test]
6274    fn allowlist_contains_no_syscall_that_must_stay_denied() {
6275        for name in SECCOMP_MUST_STAY_DENIED {
6276            assert!(
6277                !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
6278                "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
6279                 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
6280                 in the same edit and say why in the commit"
6281            );
6282        }
6283    }
6284
6285    /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
6286    /// so omitting them would refuse the child's own first `execve` and no capsule could run a
6287    /// shell tool at all. Exec is decided by the Landlock domain instead; see
6288    /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
6289    #[test]
6290    fn allowlist_permits_exec_because_landlock_now_decides_it() {
6291        for name in ["execve", "execveat"] {
6292            assert!(
6293                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
6294                "{name} must reach the kernel — the default action is a deny, so omitting it \
6295                 refuses the child's own exec and nothing runs"
6296            );
6297        }
6298    }
6299
6300    /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
6301    /// for a syscall by discarding every conditional chain already recorded for it. A bare
6302    /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
6303    /// denials at filter-build time, with nothing at runtime to show for it.
6304    #[test]
6305    fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
6306        assert!(
6307            !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
6308            "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
6309        );
6310    }
6311
6312    /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
6313    /// which is exactly the state where one of them later gets removed and the other is missed.
6314    #[test]
6315    fn allowlist_has_no_duplicates() {
6316        let mut seen = std::collections::HashSet::new();
6317        for name in SECCOMP_SYSCALL_ALLOWLIST {
6318            assert!(seen.insert(*name), "{name} appears twice in the allowlist");
6319        }
6320    }
6321
6322    /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
6323    /// still "pass" every other test here, while denying every syscall a shell needs.
6324    #[test]
6325    fn allowlist_covers_the_syscalls_no_process_can_run_without() {
6326        for name in [
6327            "read",
6328            "write",
6329            "openat",
6330            "close",
6331            "mmap",
6332            "munmap",
6333            "brk",
6334            "exit_group",
6335            "futex",
6336            "rt_sigreturn",
6337            "clone",
6338            "wait4",
6339            "getpid",
6340            "getdents64",
6341        ] {
6342            assert!(
6343                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
6344                "{name} is missing from the allowlist — no process could run under this filter"
6345            );
6346        }
6347    }
6348
6349    #[test]
6350    fn network_ip_allowed_matches_only_the_resolved_allowlist() {
6351        let allow = vec![IpAddr::from([127, 0, 0, 1])];
6352        assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
6353        assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
6354        assert!(
6355            !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
6356            "an empty resolved allowlist must deny every destination"
6357        );
6358    }
6359
6360    // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
6361
6362    #[test]
6363    fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
6364        // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
6365        // namespace, and a silent tier would imply it has no documented limits. It warns when a
6366        // shell is declared, and stays silent only when there is no subprocess to enforce on.
6367        assert_eq!(
6368            tier_warning(EnforcementTier::KernelFull, false),
6369            Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
6370        );
6371        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
6372    }
6373
6374    #[test]
6375    fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
6376        assert_eq!(
6377            tier_warning(EnforcementTier::KernelSeccompOnly, false),
6378            Some((W_SEC_002, SECCOMP_ONLY_WARNING))
6379        );
6380        assert_eq!(
6381            tier_warning(EnforcementTier::EnvironmentOnly, false),
6382            Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
6383        );
6384        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
6385        assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
6386        assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
6387    }
6388
6389    /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
6390    /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
6391    /// same condition refuses the launch outright, so a warning would describe a session that
6392    /// does not exist — and never for a capsule with no process tree to bound.
6393    #[test]
6394    fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
6395        assert_eq!(
6396            aggregate_bounding_warning(false, true, false),
6397            Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
6398        );
6399        assert_eq!(
6400            aggregate_bounding_warning(false, false, false),
6401            None,
6402            "a capsule that cannot spawn a subprocess has nothing to bound"
6403        );
6404        assert_eq!(
6405            aggregate_bounding_warning(true, true, false),
6406            None,
6407            "on Linux this case is a refused launch, not a warning"
6408        );
6409        assert_eq!(
6410            aggregate_bounding_warning(true, true, true),
6411            None,
6412            "a scope exists, so there is no gap"
6413        );
6414    }
6415
6416    /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
6417    /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
6418    /// would expect to stop a fork bomb does not.
6419    #[test]
6420    fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
6421        let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
6422        assert!(message.contains("per-uid"), "message was: {message}");
6423        assert!(message.contains("fork bomb"), "message was: {message}");
6424        assert!(message.contains("pids.max"), "message was: {message}");
6425    }
6426
6427    #[test]
6428    fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
6429        let temp = tempfile::tempdir().unwrap();
6430        warn_for_missing_aggregate_bounding(temp.path(), true, false);
6431        let log = bootstrap_log_contents(temp.path());
6432
6433        if cfg!(target_os = "linux") {
6434            assert!(
6435                log.is_empty(),
6436                "Linux refuses the launch instead of warning: {log}"
6437            );
6438        } else {
6439            assert!(log.contains(W_SEC_010), "log was: {log}");
6440            assert!(
6441                log.contains(&security_warning_link(W_SEC_010)),
6442                "log was: {log}"
6443            );
6444        }
6445    }
6446
6447    fn warning_test_policy() -> CapabilityPolicy {
6448        CapabilityPolicy {
6449            shell_allow: vec!["bash".to_string()],
6450            network_allow: vec!["https://api.example.com".to_string()],
6451            ..CapabilityPolicy::default()
6452        }
6453    }
6454
6455    #[test]
6456    fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
6457        let temp = tempfile::tempdir().unwrap();
6458        warn_for_enforcement_tier(
6459            EnforcementTier::KernelFull,
6460            temp.path(),
6461            &warning_test_policy(),
6462        );
6463        let log = bootstrap_log_contents(temp.path());
6464        assert!(
6465            log.contains(W_SEC_005),
6466            "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
6467        );
6468        assert!(
6469            log.contains("denied, not absent"),
6470            "must state the scope's documented limit: paths outside the workdir are denied, not \
6471             absent: {log}"
6472        );
6473        assert!(
6474            log.contains("read+execute") && log.contains("outside the workdir"),
6475            "must describe what is now actually granted (narrow, derived read+execute outside \
6476             the workdir), not an unfixed grant bug: {log}"
6477        );
6478        assert!(
6479            log.contains(&security_warning_link(W_SEC_005)),
6480            "must link to the diagnostics doc page: {log}"
6481        );
6482    }
6483
6484    #[test]
6485    fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
6486        let temp = tempfile::tempdir().unwrap();
6487        warn_for_enforcement_tier(
6488            EnforcementTier::KernelFull,
6489            temp.path(),
6490            &CapabilityPolicy::default(),
6491        );
6492        assert_eq!(
6493            bootstrap_log_contents(temp.path()),
6494            "",
6495            "with no shell declared there is no subprocess to enforce on — stay silent"
6496        );
6497    }
6498
6499    #[test]
6500    fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
6501        let temp = tempfile::tempdir().unwrap();
6502        warn_for_enforcement_tier(
6503            EnforcementTier::KernelSeccompOnly,
6504            temp.path(),
6505            &warning_test_policy(),
6506        );
6507        let log = bootstrap_log_contents(temp.path());
6508        assert!(
6509            log.contains("Landlock"),
6510            "must name the missing primitive: {log}"
6511        );
6512        assert!(
6513            log.contains("filesystem"),
6514            "must name what is not enforced: {log}"
6515        );
6516        assert!(
6517            log.contains(W_SEC_002),
6518            "must carry its warning code: {log}"
6519        );
6520        assert!(
6521            log.contains(&security_warning_link(W_SEC_002)),
6522            "must link to the diagnostics doc page: {log}"
6523        );
6524    }
6525
6526    #[test]
6527    fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
6528        let temp = tempfile::tempdir().unwrap();
6529        warn_for_enforcement_tier(
6530            EnforcementTier::EnvironmentOnly,
6531            temp.path(),
6532            &warning_test_policy(),
6533        );
6534        let log = bootstrap_log_contents(temp.path());
6535        assert!(
6536            log.contains("permanent"),
6537            "must state the tier is permanent, not pending: {log}"
6538        );
6539        assert!(
6540            log.contains("bash"),
6541            "the still-accurate bash/network bypass warning must also fire: {log}"
6542        );
6543        assert!(
6544            log.contains(W_SEC_001),
6545            "must carry the tier warning code: {log}"
6546        );
6547        assert!(
6548            log.contains(W_SEC_003),
6549            "must carry the bash/network bypass warning code: {log}"
6550        );
6551    }
6552
6553    // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
6554
6555    struct ForcePrepareFailureGuard;
6556
6557    impl ForcePrepareFailureGuard {
6558        fn new() -> Self {
6559            FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
6560            Self
6561        }
6562    }
6563
6564    impl Drop for ForcePrepareFailureGuard {
6565        fn drop(&mut self) {
6566            FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
6567        }
6568    }
6569
6570    #[test]
6571    fn execute_shell_fails_closed_when_enforcement_setup_fails() {
6572        let temp = tempfile::tempdir().unwrap();
6573        let policy = CapabilityPolicy {
6574            shell_allow: vec!["bash".to_string()],
6575            ..CapabilityPolicy::default()
6576        };
6577        let marker = temp.path().join("spawned-anyway.marker");
6578        let script = format!("echo ran > '{}'", marker.display());
6579
6580        {
6581            let _guard = ForcePrepareFailureGuard::new();
6582            let error = crate::shell::execute_shell(
6583                "bash",
6584                &["-c", &script],
6585                &[],
6586                temp.path(),
6587                temp.path(),
6588                &policy,
6589                &ShellEnforcement::environment_only(),
6590            )
6591            .unwrap_err();
6592            assert!(
6593                error.to_string().contains("sandbox"),
6594                "the error must name what failed to initialize: {error}"
6595            );
6596            assert!(
6597                !marker.exists(),
6598                "no subprocess may be spawned at all when enforcement setup fails"
6599            );
6600        }
6601
6602        // Control run without the forced failure: the identical call spawns and writes the
6603        // marker — proving the marker really would have caught a spawn above.
6604        let result = crate::shell::execute_shell(
6605            "bash",
6606            &["-c", &script],
6607            &[],
6608            temp.path(),
6609            temp.path(),
6610            &policy,
6611            &ShellEnforcement::environment_only(),
6612        )
6613        .unwrap();
6614        assert_eq!(result.exit_code, 0);
6615        assert!(marker.exists());
6616    }
6617}
6618
6619#[cfg(target_os = "linux")]
6620#[cfg(test)]
6621#[allow(clippy::print_stdout, clippy::print_stderr)]
6622mod landlock_probe_isolation {
6623    /// The Landlock probe must leave the calling process exactly as it found it.
6624    ///
6625    /// Stated as "the sealed probe gives the same answer before and after" rather than by
6626    /// inspecting the process's Landlock domain, because there is no API to ask whether a task
6627    /// is landlocked — and because that framing is what actually broke: the probe restricted
6628    /// itself, and every later attempt to build a composed root was refused, on every host, in
6629    /// a way that looked like the host's fault.
6630    ///
6631    /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
6632    /// halves report the same failure and this passes trivially; on a capable one, an
6633    /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
6634    #[test]
6635    fn the_landlock_probe_does_not_restrict_the_calling_process() {
6636        let before = crate::sealed::probe_sealed_support().namespace;
6637        let _ = super::linux_enforce::probe_landlock_full_access();
6638        let after = crate::sealed::probe_sealed_support().namespace;
6639
6640        assert_eq!(
6641            before, after,
6642            "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
6643             it has restricted the calling process instead of a forked child, which permanently \
6644             forbids the mount family to this process and everything it spawns",
6645        );
6646    }
6647}
6648
6649#[cfg(all(test, target_os = "linux"))]
6650#[allow(clippy::print_stdout, clippy::print_stderr)]
6651mod linux_integration_tests {
6652    use std::path::Path;
6653
6654    use super::*;
6655
6656    /// Spread-base supplying the three *host-bounding* fields (`resource_limits`,
6657    /// `cgroup_scope`, `workdir_guard`) to the enforcement literals below, which spell out the
6658    /// policy half by hand because they need to pin `tier` explicitly.
6659    ///
6660    /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
6661    /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
6662    /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
6663    /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
6664    /// of the child `pre_exec` runs on every tier), and no cgroup scope or workdir guard, so each
6665    /// spawn gets `RLIMIT_NPROC` headroom over the uid's count at that spawn. The `tier` it
6666    /// carries is always overridden by the literal that spreads it; only the host-bounding tail
6667    /// is ever consumed from here.
6668    fn host_bounding_base() -> ShellEnforcement {
6669        ShellEnforcement::environment_only()
6670    }
6671
6672    /// `<accessible>/.murmur/<session id>/`, the session workdir a `--workdir` launch stages
6673    /// into — the shape that makes the two workdirs different paths, which is the only shape in
6674    /// which putting a staging directory in the wrong one is visible.
6675    fn session_workdir_under(accessible: &Path) -> PathBuf {
6676        let session = accessible.join(".murmur").join("ses_sandbox_tests");
6677        std::fs::create_dir_all(&session).unwrap();
6678        session
6679    }
6680
6681    /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
6682    /// It pins *which* Landlock ABI v1 rights `apply_landlock_scope` hands the workdir, so
6683    /// re-adding device-node creation becomes a test failure instead of a silent regression.
6684    ///
6685    /// It proves **nothing** about whether the kernel enforces that set — that is the manual
6686    /// acceptance procedure in
6687    /// `docs/content/reference/workdir-device-node-manual-verification.md`, not this test.
6688    /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
6689    /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
6690    #[test]
6691    fn workdir_landlock_grant_withholds_device_and_socket_creation() {
6692        use landlock::{Access, AccessFs, ABI};
6693
6694        let all_v1 = AccessFs::from_all(ABI::V1);
6695        // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
6696        // unconditionally-withheld rights below are checked against the permissive case rather
6697        // than passing trivially because `Execute` happened to be absent too.
6698        let granted = linux_enforce::workdir_access_rights(true);
6699
6700        for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
6701            assert!(
6702                all_v1.contains(withheld),
6703                "{withheld:?} is expected to be part of Landlock ABI v1 — if it is not, this \
6704                 test's premise (and the workdir grant's comment) needs revisiting"
6705            );
6706            assert!(
6707                !granted.contains(withheld),
6708                "the workdir grant must withhold {withheld:?}: with the full ABI v1 set declared \
6709                 by handle_access, withholding it here is what makes it denied rather than merely \
6710                 un-granted"
6711            );
6712        }
6713
6714        assert!(
6715            granted.contains(AccessFs::MakeFifo),
6716            "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
6717        );
6718
6719        let expected = all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
6720        assert_eq!(
6721            granted, expected,
6722            "the workdir_exec grant must be exactly Landlock ABI v1 minus \
6723             MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
6724             this test and the constant's comment together"
6725        );
6726    }
6727
6728    /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
6729    /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
6730    /// procedure in
6731    /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
6732    /// regression that matters here: flipping the default back — which would silently reopen the
6733    /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
6734    #[test]
6735    fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
6736        use landlock::AccessFs;
6737
6738        assert!(
6739            !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
6740            "the default must withhold Execute from the workdir — that withholding is the whole \
6741             of what enforces capabilities.shell.allow now that the exec supervisor is gone"
6742        );
6743        assert!(
6744            linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
6745            "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
6746             compile-and-run workflows it exists for silently stop working"
6747        );
6748
6749        // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
6750        assert_eq!(
6751            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
6752            linux_enforce::workdir_access_rights(false),
6753            "workdir_exec must be a one-bit axis — no other right may ride along with it"
6754        );
6755    }
6756
6757    /// The tier gate on the workdir-backed `/tmp` fd, in both directions, against a real
6758    /// `tempfile::tempdir()` workdir. Opening an fd is an ordinary `open()` — no Landlock call, no
6759    /// namespace, no privilege — so this runs on any Linux host, unlike the enforcement it feeds.
6760    ///
6761    /// It proves **nothing** about whether the kernel then honors the rule built from that fd; that
6762    /// is the hand-run procedure in
6763    /// `docs/content/reference/sealed-containment-manual-verification.md` (step "3d — writability").
6764    /// It catches two things: that the fd is there to build a rule from at all, and that the
6765    /// directory backing it exists before `build_sealed_root` — which runs *after* this function —
6766    /// binds it.
6767    #[test]
6768    fn the_workdir_backed_tmp_store_is_opened_for_landlock_on_the_sealed_tier_only() {
6769        let accessible = tempfile::tempdir().unwrap();
6770        let session = session_workdir_under(accessible.path());
6771        let fds = linux_enforce::open_landlock_fds(
6772            accessible.path(),
6773            &session,
6774            &[],
6775            &[],
6776            false,
6777            EnforcementTier::KernelSealed,
6778        )
6779        .expect("a real, writable workdir must yield sealed Landlock fds");
6780
6781        assert!(
6782            fds.sealed_tmp_fd().is_some(),
6783            "the composed root binds {} at /tmp read-write on every sealed capsule, so its fd must \
6784             always be opened — a missing rule leaves /tmp mounted but EACCES",
6785            crate::sealed::SEALED_TMP_DIR_NAME,
6786        );
6787        assert!(
6788            session.join(crate::sealed::SEALED_TMP_DIR_NAME).is_dir(),
6789            "the /tmp store must be created here: open_landlock_fds runs before build_sealed_root, \
6790             so nothing else has made this directory yet",
6791        );
6792        assert!(
6793            !accessible
6794                .path()
6795                .join(crate::sealed::SEALED_TMP_DIR_NAME)
6796                .exists(),
6797            "enabling sealed must add nothing to the top level of the accessible workdir — that \
6798             directory is the capsule's deliverable",
6799        );
6800
6801        for tier in [
6802            EnforcementTier::KernelFull,
6803            EnforcementTier::KernelSeccompOnly,
6804            EnforcementTier::EnvironmentOnly,
6805        ] {
6806            let accessible = tempfile::tempdir().unwrap();
6807            let session = session_workdir_under(accessible.path());
6808            let fds = linux_enforce::open_landlock_fds(
6809                accessible.path(),
6810                &session,
6811                &[],
6812                &[],
6813                false,
6814                tier,
6815            )
6816            .expect("a real, writable workdir must yield Landlock fds on any tier");
6817
6818            assert!(
6819                fds.sealed_tmp_fd().is_none(),
6820                "{tier:?} composes no root and binds nothing at /tmp, so granting a workdir-backed \
6821                 /tmp would grant a path this tier's capsule never sees",
6822            );
6823            assert!(
6824                !session.join(crate::sealed::SEALED_TMP_DIR_NAME).exists(),
6825                "{tier:?} must not even create the store — an unbound directory in the session \
6826                 workdir is litter the capsule can see",
6827            );
6828        }
6829    }
6830
6831    /// The synthetic account databases, written and opened by the same parent-side call, against a
6832    /// real `tempfile::tempdir()` workdir.
6833    ///
6834    /// Like the `/tmp` test above it proves nothing about kernel enforcement — that is the hand-run
6835    /// in `docs/content/reference/sealed-containment-manual-verification.md`. It catches three
6836    /// things: that the files exist before `build_sealed_root` binds them, that there is an fd to
6837    /// build each rule from (without one, Landlock matches the *host's* `/etc/passwd` inode and the
6838    /// bound synthetic file is mounted-but-`EACCES`), and that neither is created on a tier that
6839    /// composes no root.
6840    #[test]
6841    fn the_synthetic_etc_files_are_written_and_opened_on_the_sealed_tier_only() {
6842        let accessible = tempfile::tempdir().unwrap();
6843        let session = session_workdir_under(accessible.path());
6844        let fds = linux_enforce::open_landlock_fds(
6845            accessible.path(),
6846            &session,
6847            &[],
6848            &[],
6849            false,
6850            EnforcementTier::KernelSealed,
6851        )
6852        .expect("a real, writable workdir must yield sealed Landlock fds");
6853
6854        let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
6855            .iter()
6856            .filter(|entry| entry.synthetic.is_some())
6857            .collect();
6858        assert_eq!(
6859            fds.sealed_identity_fd_count(),
6860            synthetic.len(),
6861            "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
6862        );
6863        for entry in &synthetic {
6864            let path = crate::sealed::synthetic_etc_source(&session, entry.synthetic.unwrap());
6865            assert!(
6866                path.is_file(),
6867                "{} must be written here: open_landlock_fds runs before build_sealed_root, so \
6868                 nothing else has written it yet",
6869                entry.path,
6870            );
6871        }
6872        assert!(
6873            !accessible
6874                .path()
6875                .join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME)
6876                .exists(),
6877            "the staging directory belongs to the session, not to the capsule's deliverable",
6878        );
6879
6880        for tier in [
6881            EnforcementTier::KernelFull,
6882            EnforcementTier::KernelSeccompOnly,
6883            EnforcementTier::EnvironmentOnly,
6884        ] {
6885            let accessible = tempfile::tempdir().unwrap();
6886            let session = session_workdir_under(accessible.path());
6887            let fds = linux_enforce::open_landlock_fds(
6888                accessible.path(),
6889                &session,
6890                &[],
6891                &[],
6892                false,
6893                tier,
6894            )
6895            .expect("a real, writable workdir must yield Landlock fds on any tier");
6896
6897            assert_eq!(
6898                fds.sealed_identity_fd_count(),
6899                0,
6900                "{tier:?} composes no root, so its capsule reads the host's real /etc/passwd and \
6901                 these files stand in for nothing",
6902            );
6903            assert!(
6904                !session
6905                    .join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME)
6906                    .exists(),
6907                "{tier:?} must not even create the staging directory — same reason as .mur-tmp",
6908            );
6909        }
6910    }
6911
6912    /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
6913    ///
6914    /// Both are derived from `shell::SYNTHETIC_HOME_DIR_NAME`, and this asserts the end result
6915    /// rather than the constant — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and
6916    /// `os.environ["HOME"]`, and a disagreement between them is silent until a tool picks the
6917    /// wrong one. The uid/gid are checked against the workdir's own owner, which is where
6918    /// `ensure_sealed_identity_files` reads the capsule's identity from.
6919    #[test]
6920    fn the_synthetic_passwd_entry_carries_exactly_the_home_the_subprocess_env_gets() {
6921        use std::os::unix::fs::MetadataExt;
6922
6923        let accessible = tempfile::tempdir().unwrap();
6924        let session = session_workdir_under(accessible.path());
6925        let written = ensure_sealed_identity_files(&session).unwrap();
6926        let env = crate::shell::build_shell_env(
6927            &crate::types::CapabilityPolicy::default(),
6928            &[],
6929            &session,
6930        )
6931        .unwrap();
6932
6933        let passwd = std::fs::read_to_string(&written[0]).unwrap();
6934        let owner = std::fs::metadata(&session).unwrap();
6935        let entry: Vec<&str> = passwd
6936            .lines()
6937            .last()
6938            .expect("the capsule's own entry is the last line")
6939            .split(':')
6940            .collect();
6941
6942        assert_eq!(entry[2], owner.uid().to_string(), "uid field");
6943        assert_eq!(entry[3], owner.gid().to_string(), "gid field");
6944        assert_eq!(
6945            Some(&entry[5].to_string()),
6946            env.get("HOME"),
6947            "pw_dir and $HOME are the same string or the entry is worse than useless",
6948        );
6949
6950        let group = std::fs::read_to_string(&written[1]).unwrap();
6951        assert_eq!(
6952            group.lines().last().unwrap().split(':').nth(2),
6953            Some(owner.gid().to_string().as_str()),
6954            "gid field of the capsule's own group",
6955        );
6956    }
6957
6958    /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
6959    /// no fork, no spawn.
6960    ///
6961    /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
6962    /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
6963    /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
6964    /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
6965    /// manager creating build directories — and the `Execute` axis, which must track
6966    /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
6967    /// the same storage and a binary staged in one is the same bytes as a binary staged in the
6968    /// other.
6969    #[test]
6970    fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
6971        use landlock::AccessFs;
6972
6973        for workdir_exec in [false, true] {
6974            let rights = linux_enforce::workdir_access_rights(workdir_exec);
6975
6976            for needed in [
6977                AccessFs::WriteFile,
6978                AccessFs::ReadFile,
6979                AccessFs::ReadDir,
6980                AccessFs::MakeReg,
6981                AccessFs::MakeDir,
6982                AccessFs::RemoveFile,
6983                AccessFs::RemoveDir,
6984            ] {
6985                assert!(
6986                    rights.contains(needed),
6987                    "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
6988                     `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
6989                );
6990            }
6991
6992            assert_eq!(
6993                rights.contains(AccessFs::Execute),
6994                workdir_exec,
6995                "/tmp is backed by a directory inside the workdir, so Execute there must follow \
6996                 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
6997                 make /tmp a hole through which capsule-written bytes run",
6998            );
6999        }
7000
7001        assert_eq!(
7002            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
7003            linux_enforce::workdir_access_rights(false),
7004            "the /tmp rule and the workdir rule share one right-set binding in \
7005             apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
7006             that binding must be revisited together",
7007        );
7008    }
7009
7010    #[test]
7011    fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
7012        let mut command = std::process::Command::new("true");
7013        let enforcement = ShellEnforcement::environment_only();
7014        let supervisor = prepare_enforcement(
7015            &mut command,
7016            &enforcement,
7017            Path::new("/tmp"),
7018            Path::new("/tmp"),
7019        )
7020        .expect("environment_only must never fail");
7021        supervisor.join_best_effort();
7022    }
7023
7024    // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
7025    // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
7026    // test passes while exercising a path that enforces nothing. The two tests below assert the
7027    // opposite direction — that allowlisted binaries still run — which any tier does evidence.
7028
7029    #[test]
7030    fn kernel_tier_allows_exec_within_shell_allowlist() {
7031        if crate::network_namespace::skip_without_egress_namespace(
7032            "kernel_tier_allows_exec_within_shell_allowlist",
7033        ) {
7034            return;
7035        }
7036        let tier = detect_enforcement_tier();
7037        if tier == EnforcementTier::EnvironmentOnly {
7038            eprintln!(
7039                "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
7040                 EnvironmentOnly on a Linux host — degrading gracefully"
7041            );
7042            return;
7043        }
7044
7045        let temp = tempfile::tempdir().unwrap();
7046        let policy = CapabilityPolicy {
7047            shell_allow: vec!["bash".to_string()],
7048            ..CapabilityPolicy::default()
7049        };
7050        let enforcement = ShellEnforcement {
7051            tier,
7052            network_allow_ips: Vec::new(),
7053            unix_sockets_allowed: policy.unix_sockets_allowed,
7054            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7055                &resolve_exec_allowlist(&policy.shell_allow),
7056            )),
7057            ..host_bounding_base()
7058        };
7059
7060        let result = crate::shell::execute_shell(
7061            "bash",
7062            &["-c", "exit 7"],
7063            &[],
7064            temp.path(),
7065            temp.path(),
7066            &policy,
7067            &enforcement,
7068        )
7069        .unwrap();
7070
7071        assert_eq!(
7072            result.exit_code, 7,
7073            "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
7074             proving the allowlist isn't a blanket deny"
7075        );
7076    }
7077
7078    #[test]
7079    fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
7080        if crate::network_namespace::skip_without_egress_namespace(
7081            "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
7082        ) {
7083            return;
7084        }
7085        let tier = detect_enforcement_tier();
7086        if tier == EnforcementTier::EnvironmentOnly {
7087            eprintln!(
7088                "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
7089                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
7090            );
7091            return;
7092        }
7093
7094        let temp = tempfile::tempdir().unwrap();
7095        let policy = CapabilityPolicy {
7096            shell_allow: vec!["bash".to_string(), "ls".to_string()],
7097            ..CapabilityPolicy::default()
7098        };
7099        let enforcement = ShellEnforcement {
7100            tier,
7101            network_allow_ips: Vec::new(),
7102            unix_sockets_allowed: policy.unix_sockets_allowed,
7103            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7104                &resolve_exec_allowlist(&policy.shell_allow),
7105            )),
7106            ..host_bounding_base()
7107        };
7108
7109        // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
7110        // bash — the case the no-regression scenario ("allowlisted binary still functions")
7111        // actually cares about.
7112        let result = crate::shell::execute_shell(
7113            "bash",
7114            &["-c", "ls"],
7115            &[],
7116            temp.path(),
7117            temp.path(),
7118            &policy,
7119            &enforcement,
7120        )
7121        .unwrap();
7122
7123        assert_eq!(
7124            result.exit_code, 0,
7125            "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
7126             (stderr: {})",
7127            result.stderr
7128        );
7129    }
7130
7131    #[test]
7132    fn kernel_tier_denies_network_connect_outside_allowlist() {
7133        if crate::network_namespace::skip_without_egress_namespace(
7134            "kernel_tier_denies_network_connect_outside_allowlist",
7135        ) {
7136            return;
7137        }
7138        let tier = detect_enforcement_tier();
7139        if tier == EnforcementTier::EnvironmentOnly {
7140            eprintln!(
7141                "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
7142                 EnvironmentOnly on a Linux host — degrading gracefully"
7143            );
7144            return;
7145        }
7146
7147        // A real listener on the *host*, so the destination port is genuinely open. The failure
7148        // below is therefore about reachability, not about a closed port: the subprocess runs in
7149        // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
7150        // listener does not exist at all.
7151        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
7152        let port = listener.local_addr().unwrap().port();
7153
7154        let temp = tempfile::tempdir().unwrap();
7155        let policy = CapabilityPolicy {
7156            shell_allow: vec!["bash".to_string()],
7157            ..CapabilityPolicy::default()
7158        };
7159        let enforcement = ShellEnforcement {
7160            tier,
7161            // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
7162            // capsule found its way to the endpoint.
7163            network_allow_rules: Vec::new(),
7164            network_allow_ips: Vec::new(),
7165            unix_sockets_allowed: policy.unix_sockets_allowed,
7166            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7167                &resolve_exec_allowlist(&policy.shell_allow),
7168            )),
7169            ..host_bounding_base()
7170        };
7171
7172        // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
7173        let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
7174        let result = crate::shell::execute_shell(
7175            "bash",
7176            &["-c", &script],
7177            &[],
7178            temp.path(),
7179            temp.path(),
7180            &policy,
7181            &enforcement,
7182        )
7183        .unwrap();
7184
7185        assert_ne!(
7186            result.exit_code, 0,
7187            "a direct connect() to a destination outside capabilities.network.allow must fail \
7188             even though the port is really open on the host — the subprocess is in its own \
7189             network namespace with no route to it"
7190        );
7191    }
7192
7193    /// The no-regression counterpart of the test above: enforcement must add denials for
7194    /// out-of-policy destinations without breaking permitted ones.
7195    ///
7196    /// The allowlist has to be declared rather than only pre-resolved, because it decides two
7197    /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
7198    /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
7199    ///
7200    /// Deliberately *not* a test of the security property — a green suite proves nothing about
7201    /// the kernel-level claim, and the real check is the hand-run procedure in
7202    /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
7203    /// asserts only that the permitted path still functions.
7204    #[test]
7205    fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
7206        if crate::network_namespace::skip_without_egress_namespace(
7207            "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
7208        ) {
7209            return;
7210        }
7211        let tier = detect_enforcement_tier();
7212        if tier == EnforcementTier::EnvironmentOnly {
7213            eprintln!(
7214                "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
7215                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
7216            );
7217            return;
7218        }
7219
7220        // A real listener on the host, answering one connection, so a success below means bytes
7221        // really crossed the namespace boundary through the proxy rather than merely connecting.
7222        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
7223        let port = listener.local_addr().unwrap().port();
7224        let server = std::thread::spawn(move || {
7225            if let Ok((mut stream, _)) = listener.accept() {
7226                use std::io::Write;
7227                let _ = stream.write_all(b"pong\n");
7228            }
7229        });
7230
7231        let temp = tempfile::tempdir().unwrap();
7232        // Naming the port is required, not incidental: it is what puts a listener on that port
7233        // inside the namespace. See `egress_proxy::egress_listen_ports`.
7234        let allow = vec![format!("http://127.0.0.1:{port}")];
7235        let policy = CapabilityPolicy {
7236            shell_allow: vec!["bash".to_string()],
7237            network_allow: allow.clone(),
7238            ..CapabilityPolicy::default()
7239        };
7240        let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
7241        let enforcement = ShellEnforcement {
7242            tier,
7243            egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
7244            network_allow_rules: rules,
7245            network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
7246            unix_sockets_allowed: policy.unix_sockets_allowed,
7247            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7248                &resolve_exec_allowlist(&policy.shell_allow),
7249            )),
7250            ..host_bounding_base()
7251        };
7252
7253        // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
7254        // and this measures the network path alone.
7255        let script =
7256            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
7257        let result = crate::shell::execute_shell(
7258            "bash",
7259            &["-c", &script],
7260            &[],
7261            temp.path(),
7262            temp.path(),
7263            &policy,
7264            &enforcement,
7265        )
7266        .unwrap();
7267        let _ = server.join();
7268
7269        assert_eq!(
7270            result.exit_code, 0,
7271            "a destination inside capabilities.network.allow must stay reachable — enforcement \
7272             adds denials for out-of-policy destinations, it does not degrade permitted ones \
7273             (stderr: {})",
7274            result.stderr
7275        );
7276        assert!(
7277            result.stdout.contains("pong"),
7278            "the host listener's bytes must come back through the proxy unmodified \
7279             (stdout: {:?}, stderr: {})",
7280            result.stdout,
7281            result.stderr
7282        );
7283    }
7284
7285    #[test]
7286    fn kernel_full_denies_filesystem_access_outside_workdir() {
7287        if crate::network_namespace::skip_without_egress_namespace(
7288            "kernel_full_denies_filesystem_access_outside_workdir",
7289        ) {
7290            return;
7291        }
7292        let tier = detect_enforcement_tier();
7293        if tier != EnforcementTier::KernelFull {
7294            eprintln!(
7295                "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
7296                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
7297                 instead"
7298            );
7299            return;
7300        }
7301
7302        let temp = tempfile::tempdir().unwrap();
7303        let policy = CapabilityPolicy {
7304            shell_allow: vec!["bash".to_string()],
7305            ..CapabilityPolicy::default()
7306        };
7307        let enforcement = ShellEnforcement {
7308            tier,
7309            network_allow_ips: Vec::new(),
7310            unix_sockets_allowed: policy.unix_sockets_allowed,
7311            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7312                &resolve_exec_allowlist(&policy.shell_allow),
7313            )),
7314            ..host_bounding_base()
7315        };
7316
7317        let result = crate::shell::execute_shell(
7318            "bash",
7319            &["-c", "cat /etc/hostname"],
7320            &[],
7321            temp.path(),
7322            temp.path(),
7323            &policy,
7324            &enforcement,
7325        )
7326        .unwrap();
7327
7328        assert_ne!(
7329            result.exit_code, 0,
7330            "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
7331        );
7332    }
7333
7334    // The two tests below exercise the derived Landlock grants. A green run is not evidence they
7335    // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
7336    // no Landlock domain is installed at all. Both tests print a skip line and return when the
7337    // host is not `KernelFull`.
7338
7339    #[test]
7340    fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
7341    {
7342        if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
7343            return;
7344        }
7345        let tier = detect_enforcement_tier();
7346        if tier != EnforcementTier::KernelFull {
7347            eprintln!(
7348                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7349                 kernel_full_runs_nontrivial_shell_allowlist_... requires \
7350                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7351                 This run does NOT execute the derived-grant Landlock code path at all, so a \
7352                 green result here is not evidence the workdir-scope exec bug is fixed — only a \
7353                 real KernelFull Linux host can prove that."
7354            );
7355            return;
7356        }
7357
7358        let temp = tempfile::tempdir().unwrap();
7359        // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
7360        // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
7361        // Linux hosts and both live outside the workdir, so this only succeeds if the derived
7362        // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
7363        let policy = CapabilityPolicy {
7364            shell_allow: vec!["bash".to_string(), "cat".to_string()],
7365            ..CapabilityPolicy::default()
7366        };
7367        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
7368        let enforcement = ShellEnforcement {
7369            tier,
7370            network_allow_ips: Vec::new(),
7371            unix_sockets_allowed: policy.unix_sockets_allowed,
7372            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7373                &exec_allow_paths,
7374            )),
7375            ..host_bounding_base()
7376        };
7377
7378        // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
7379        // nested, dynamically-linked exec — the case the whole fix exists for.
7380        let result = crate::shell::execute_shell(
7381            "bash",
7382            &["-c", "echo landlock-grant-ok | cat"],
7383            &[],
7384            temp.path(),
7385            temp.path(),
7386            &policy,
7387            &enforcement,
7388        )
7389        .expect("execute_shell should return Ok, not Err");
7390
7391        assert_eq!(
7392            result.exit_code, 0,
7393            "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
7394             must let each exec and dynamic-link outside the workdir (stderr: {})",
7395            result.stderr
7396        );
7397        assert!(
7398            result.stdout.contains("landlock-grant-ok"),
7399            "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
7400            result.stdout,
7401            result.stderr
7402        );
7403    }
7404
7405    #[test]
7406    fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
7407        if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
7408            return;
7409        }
7410        let tier = detect_enforcement_tier();
7411        if tier != EnforcementTier::KernelFull {
7412            eprintln!(
7413                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7414                 kernel_full_denies_write_outside_workdir_... requires \
7415                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7416                 This run does NOT execute the Landlock code path, so a green result here is not \
7417                 evidence writes outside the workdir are actually denied — only a real KernelFull \
7418                 Linux host can prove that."
7419            );
7420            return;
7421        }
7422
7423        let workdir = tempfile::tempdir().unwrap();
7424        // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
7425        // reason a write can fail is the Landlock scope — not a missing directory.
7426        let outside = tempfile::tempdir().unwrap();
7427        let target = outside.path().join("should-not-be-written.txt");
7428
7429        let policy = CapabilityPolicy {
7430            shell_allow: vec!["bash".to_string()],
7431            ..CapabilityPolicy::default()
7432        };
7433        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
7434        let enforcement = ShellEnforcement {
7435            tier,
7436            network_allow_ips: Vec::new(),
7437            unix_sockets_allowed: policy.unix_sockets_allowed,
7438            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7439                &exec_allow_paths,
7440            )),
7441            ..host_bounding_base()
7442        };
7443
7444        // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
7445        // write itself is what Landlock must refuse. The derived read+execute grants never widen
7446        // to write, so nothing outside the workdir (grant paths included) is writable.
7447        let script = format!("echo pwned > '{}'", target.display());
7448        let result = crate::shell::execute_shell(
7449            "bash",
7450            &["-c", &script],
7451            &[],
7452            workdir.path(),
7453            workdir.path(),
7454            &policy,
7455            &enforcement,
7456        )
7457        .expect("execute_shell should return Ok with a nonzero exit code, not Err");
7458
7459        assert!(
7460            !target.exists(),
7461            "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
7462        );
7463        assert_ne!(
7464            result.exit_code, 0,
7465            "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
7466        );
7467    }
7468
7469    /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
7470    /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
7471    /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
7472    fn kernel_full_enforcement(
7473        tier: EnforcementTier,
7474        policy: &CapabilityPolicy,
7475    ) -> ShellEnforcement {
7476        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
7477        let mut landlock_grants =
7478            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
7479        landlock_grants.extend(resolve_interpreter_runtime_grants(
7480            &policy.shell_interpreter_runtime,
7481        ));
7482        ShellEnforcement {
7483            tier,
7484            network_allow_ips: Vec::new(),
7485            unix_sockets_allowed: policy.unix_sockets_allowed,
7486            landlock_grants,
7487            ..host_bounding_base()
7488        }
7489    }
7490
7491    /// A real sealed run against two different workdirs: the composed root's `/tmp` and its
7492    /// synthetic `/etc/passwd` both have to resolve from the session workdir, and the capsule's
7493    /// own directory has to gain nothing from either.
7494    ///
7495    /// This is the one test that drives a composed root end to end, so it needs a host that can
7496    /// actually reach `KernelSealed` and skips loudly on one that cannot; the parent-side halves
7497    /// are covered unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports
7498    /// the same host answer this checks.
7499    #[test]
7500    fn a_sealed_command_finds_tmp_and_etc_after_they_moved_into_the_session_workdir() {
7501        if crate::network_namespace::skip_without_egress_namespace(
7502            "a_sealed_command_finds_tmp_and_etc_after_they_moved_into_the_session_workdir",
7503        ) {
7504            return;
7505        }
7506        let probe = HostProbe::probe();
7507        if probe.tier() != EnforcementTier::KernelSealed {
7508            eprintln!(
7509                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
7510                 a_sealed_command_finds_tmp_and_etc_... needs a host that reaches KernelSealed \
7511                 (unprivileged user namespaces plus a usable Landlock ABI); detected {:?}. No \
7512                 composed root is built in this run.",
7513                probe.tier()
7514            );
7515            return;
7516        }
7517
7518        let accessible = tempfile::tempdir().unwrap();
7519        let session = accessible.path().join(".murmur").join("ses_sealed_e2e");
7520        std::fs::create_dir_all(&session).unwrap();
7521        let policy = CapabilityPolicy {
7522            shell_allow: vec!["bash".to_string(), "cat".to_string(), "id".to_string()],
7523            ..CapabilityPolicy::default()
7524        };
7525        let enforcement =
7526            ShellEnforcement::resolve(&policy, murmur_artifact::ContainmentClass::Sealed, probe)
7527                .expect("a sealed-capable host resolves sealed enforcement");
7528        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
7529
7530        let result = crate::shell::execute_shell(
7531            "bash",
7532            &["-c", "echo t > /tmp/probe && cat /tmp/probe && id -un"],
7533            &[],
7534            accessible.path(),
7535            &session,
7536            &policy,
7537            &enforcement,
7538        )
7539        .expect("execute_shell returns Ok even when the command itself fails");
7540
7541        assert_eq!(
7542            result.exit_code, 0,
7543            "stdout: {} stderr: {}",
7544            result.stdout, result.stderr
7545        );
7546        let mut lines = result.stdout.lines();
7547        assert_eq!(
7548            lines.next(),
7549            Some("t"),
7550            "the capsule's /tmp must be writable"
7551        );
7552        assert!(
7553            lines.next().is_some_and(|name| !name.is_empty()),
7554            "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
7555             {}",
7556            result.stdout
7557        );
7558
7559        assert_eq!(
7560            std::fs::read_to_string(
7561                session
7562                    .join(crate::sealed::SEALED_TMP_DIR_NAME)
7563                    .join("probe")
7564            )
7565            .unwrap(),
7566            "t\n",
7567            "the bytes written to /tmp land in the session workdir's own store"
7568        );
7569        assert!(session
7570            .join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME)
7571            .join("passwd")
7572            .is_file());
7573        for name in [
7574            crate::sealed::SEALED_TMP_DIR_NAME,
7575            crate::sealed::SEALED_ETC_STAGING_DIR_NAME,
7576            crate::shell::SYNTHETIC_HOME_DIR_NAME,
7577        ] {
7578            assert!(
7579                !accessible.path().join(name).exists(),
7580                "enabling sealed must add nothing to the top level of the accessible workdir, \
7581                 and it added {name}"
7582            );
7583        }
7584    }
7585
7586    // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
7587    // return when the host is not `KernelFull`.
7588
7589    #[test]
7590    fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
7591        if crate::network_namespace::skip_without_egress_namespace(
7592            "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
7593        ) {
7594            return;
7595        }
7596        let tier = detect_enforcement_tier();
7597        if tier != EnforcementTier::KernelFull {
7598            eprintln!(
7599                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7600                 kernel_full_interpreter_runtime_list_dir_false_... requires \
7601                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7602                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
7603                 result here is not evidence list_dir:false behaves correctly — only a real \
7604                 KernelFull Linux host can prove that."
7605            );
7606            return;
7607        }
7608
7609        let workdir = tempfile::tempdir().unwrap();
7610        // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
7611        let stdlib = tempfile::tempdir().unwrap();
7612        let module = stdlib.path().join("mod.py");
7613        std::fs::write(&module, "x = 1\n").unwrap();
7614
7615        let policy = CapabilityPolicy {
7616            shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
7617            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7618                binary: "bash".to_string(),
7619                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7620                    path: stdlib.path().to_string_lossy().into_owned(),
7621                    list_dir: false,
7622                }],
7623            }],
7624            ..CapabilityPolicy::default()
7625        };
7626        let enforcement = kernel_full_enforcement(tier, &policy);
7627
7628        // Opening the file by its exact known name must succeed — Landlock's read rights apply to
7629        // the subtree beneath the granted directory.
7630        let read = crate::shell::execute_shell(
7631            "bash",
7632            &["-c", &format!("cat '{}'", module.display())],
7633            &[],
7634            workdir.path(),
7635            workdir.path(),
7636            &policy,
7637            &enforcement,
7638        )
7639        .expect("execute_shell should return Ok");
7640        assert_eq!(
7641            read.exit_code, 0,
7642            "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
7643            read.stderr
7644        );
7645        assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
7646
7647        // Listing the directory's own entries must fail — ReadDir was not granted.
7648        let list = crate::shell::execute_shell(
7649            "bash",
7650            &["-c", &format!("ls '{}'", stdlib.path().display())],
7651            &[],
7652            workdir.path(),
7653            workdir.path(),
7654            &policy,
7655            &enforcement,
7656        )
7657        .expect("execute_shell should return Ok with a nonzero exit code");
7658        assert_ne!(
7659            list.exit_code, 0,
7660            "list_dir:false must deny enumerating the granted directory's own entries"
7661        );
7662    }
7663
7664    #[test]
7665    fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
7666        if crate::network_namespace::skip_without_egress_namespace(
7667            "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
7668        ) {
7669            return;
7670        }
7671        let tier = detect_enforcement_tier();
7672        if tier != EnforcementTier::KernelFull {
7673            eprintln!(
7674                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7675                 kernel_full_interpreter_runtime_list_dir_true_... requires \
7676                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7677                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
7678                 result here is not evidence list_dir:true (and its non-widening to the parent) \
7679                 behaves correctly — only a real KernelFull Linux host can prove that."
7680            );
7681            return;
7682        }
7683
7684        let workdir = tempfile::tempdir().unwrap();
7685        // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
7686        let parent = tempfile::tempdir().unwrap();
7687        let stdlib = parent.path().join("stdlib");
7688        std::fs::create_dir(&stdlib).unwrap();
7689        std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
7690
7691        let policy = CapabilityPolicy {
7692            shell_allow: vec!["bash".to_string(), "ls".to_string()],
7693            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7694                binary: "bash".to_string(),
7695                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7696                    path: stdlib.to_string_lossy().into_owned(),
7697                    list_dir: true,
7698                }],
7699            }],
7700            ..CapabilityPolicy::default()
7701        };
7702        let enforcement = kernel_full_enforcement(tier, &policy);
7703
7704        // Listing the granted directory must succeed.
7705        let list = crate::shell::execute_shell(
7706            "bash",
7707            &["-c", &format!("ls '{}'", stdlib.display())],
7708            &[],
7709            workdir.path(),
7710            workdir.path(),
7711            &policy,
7712            &enforcement,
7713        )
7714        .expect("execute_shell should return Ok");
7715        assert_eq!(
7716            list.exit_code, 0,
7717            "list_dir:true must permit enumerating the granted directory (stderr: {})",
7718            list.stderr
7719        );
7720        assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
7721
7722        // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
7723        // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
7724        let list_parent = crate::shell::execute_shell(
7725            "bash",
7726            &["-c", &format!("ls '{}'", parent.path().display())],
7727            &[],
7728            workdir.path(),
7729            workdir.path(),
7730            &policy,
7731            &enforcement,
7732        )
7733        .expect("execute_shell should return Ok with a nonzero exit code");
7734        assert_ne!(
7735            list_parent.exit_code, 0,
7736            "a grant on stdlib/ must not make its (ungranted) parent enumerable"
7737        );
7738    }
7739
7740    /// The `executable` axis, asserted against a real Landlock kernel.
7741    ///
7742    /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
7743    /// readable and enumerable without becoming a place to run programs from. It is asserted here
7744    /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
7745    /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
7746    /// would have to be driven through a composed root, which no unit test can reach.
7747    ///
7748    /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
7749    /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
7750    #[test]
7751    fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
7752        if crate::network_namespace::skip_without_egress_namespace(
7753            "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
7754        ) {
7755            return;
7756        }
7757        let host_tier = detect_enforcement_tier();
7758        if !matches!(
7759            host_tier,
7760            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
7761        ) {
7762            eprintln!(
7763                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7764                 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
7765                 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
7766                 result is not evidence that withholding Execute denies execve."
7767            );
7768            return;
7769        }
7770
7771        let workdir = tempfile::tempdir().unwrap();
7772        // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
7773        let tree = tempfile::tempdir().unwrap();
7774        std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
7775        let runme = tree.path().join("runme");
7776        std::fs::copy("/bin/echo", &runme).unwrap();
7777
7778        let policy = CapabilityPolicy {
7779            shell_allow: vec!["bash".to_string(), "ls".to_string()],
7780            ..CapabilityPolicy::default()
7781        };
7782        let with_tree_grant = |executable: bool| {
7783            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
7784            enforcement.landlock_grants.push(LandlockGrant {
7785                path: tree.path().to_path_buf(),
7786                list_dir: true,
7787                executable,
7788            });
7789            enforcement
7790        };
7791        let run = |enforcement: &ShellEnforcement, command: String| {
7792            crate::shell::execute_shell(
7793                "bash",
7794                &["-c", &command],
7795                &[],
7796                workdir.path(),
7797                workdir.path(),
7798                &policy,
7799                enforcement,
7800            )
7801            .expect("execute_shell should return Ok even when the command itself fails")
7802        };
7803
7804        let denied = with_tree_grant(false);
7805        let list = run(&denied, format!("ls '{}'", tree.path().display()));
7806        assert_eq!(
7807            list.exit_code, 0,
7808            "a non-executable grant must still be enumerable — that is its entire purpose \
7809             (stderr: {})",
7810            list.stderr
7811        );
7812        assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
7813
7814        let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
7815        assert_ne!(
7816            exec_denied.exit_code, 0,
7817            "withholding Execute must deny execve inside the granted tree; a program ran instead \
7818             (stdout: {})",
7819            exec_denied.stdout
7820        );
7821        assert!(
7822            !exec_denied.stdout.contains("ran"),
7823            "the binary produced output, so it executed: {}",
7824            exec_denied.stdout
7825        );
7826
7827        // The control: the identical grant with `executable: true` does run it. Without this the
7828        // assertion above could be passing for an unrelated reason (a bad path, a missing library).
7829        let allowed = with_tree_grant(true);
7830        let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
7831        assert_eq!(
7832            exec_allowed.exit_code, 0,
7833            "the same tree granted Execute must run (stderr: {})",
7834            exec_allowed.stderr
7835        );
7836        assert!(
7837            exec_allowed.stdout.contains("ran"),
7838            "stdout: {}",
7839            exec_allowed.stdout
7840        );
7841    }
7842
7843    /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
7844    /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
7845    ///
7846    /// Two kernel facts are pinned here. First, `ReadFile` alone —
7847    /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
7848    /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
7849    /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
7850    /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
7851    /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
7852    /// read, not die in `pre_exec`.
7853    ///
7854    /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
7855    #[test]
7856    fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
7857        if crate::network_namespace::skip_without_egress_namespace(
7858            "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
7859        ) {
7860            return;
7861        }
7862        let host_tier = detect_enforcement_tier();
7863        if !matches!(
7864            host_tier,
7865            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
7866        ) {
7867            eprintln!(
7868                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7869                 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
7870                 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
7871                 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
7872                 a file is narrowed instead of refusing the launch."
7873            );
7874            return;
7875        }
7876
7877        let workdir = tempfile::tempdir().unwrap();
7878        // A trust-store stand-in: a regular file outside the workdir, granted on its own.
7879        let store = tempfile::tempdir().unwrap();
7880        let bundle = store.path().join("ca-certificates.crt");
7881        std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
7882
7883        let policy = CapabilityPolicy {
7884            shell_allow: vec!["bash".to_string(), "cat".to_string()],
7885            ..CapabilityPolicy::default()
7886        };
7887        let with_file_grant = |list_dir: bool| {
7888            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
7889            enforcement.landlock_grants.push(LandlockGrant {
7890                path: bundle.clone(),
7891                list_dir,
7892                executable: false,
7893            });
7894            enforcement
7895        };
7896        let read = |enforcement: &ShellEnforcement| {
7897            crate::shell::execute_shell(
7898                "bash",
7899                &["-c", &format!("cat '{}'", bundle.display())],
7900                &[],
7901                workdir.path(),
7902                workdir.path(),
7903                &policy,
7904                enforcement,
7905            )
7906            .expect("execute_shell should return Ok even when the command itself fails")
7907        };
7908
7909        let honest = read(&with_file_grant(false));
7910        assert_eq!(
7911            honest.exit_code, 0,
7912            "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
7913             file entries rely on (stderr: {})",
7914            honest.stderr
7915        );
7916        assert!(
7917            honest.stdout.contains("BEGIN CERTIFICATE"),
7918            "stdout: {}",
7919            honest.stdout
7920        );
7921
7922        let overclaimed = read(&with_file_grant(true));
7923        assert_eq!(
7924            overclaimed.exit_code, 0,
7925            "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
7926             into an EINVAL that refuses the launch (stderr: {})",
7927            overclaimed.stderr
7928        );
7929        assert!(
7930            overclaimed.stdout.contains("BEGIN CERTIFICATE"),
7931            "stdout: {}",
7932            overclaimed.stdout
7933        );
7934    }
7935
7936    // ---- every distinct pre_exec setup failure is legible + fail-closed ----
7937    //
7938    // These force each of the three distinct sandbox setup failures and assert each produces its
7939    // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
7940    // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
7941    // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
7942    // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
7943    // short-circuits before any real Landlock syscall.
7944
7945    struct ForceNoNewPrivsFailureGuard;
7946
7947    impl ForceNoNewPrivsFailureGuard {
7948        fn new() -> Self {
7949            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
7950            Self
7951        }
7952    }
7953
7954    impl Drop for ForceNoNewPrivsFailureGuard {
7955        fn drop(&mut self) {
7956            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
7957        }
7958    }
7959
7960    struct ForceLandlockFailureGuard;
7961
7962    impl ForceLandlockFailureGuard {
7963        fn new() -> Self {
7964            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
7965            Self
7966        }
7967    }
7968
7969    impl Drop for ForceLandlockFailureGuard {
7970        fn drop(&mut self) {
7971            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
7972        }
7973    }
7974
7975    /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
7976    /// depend on any specific grant — they force the failure directly — so this exercises the
7977    /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
7978    fn kernel_full_empty_grants() -> ShellEnforcement {
7979        ShellEnforcement {
7980            tier: EnforcementTier::KernelFull,
7981            network_allow_ips: Vec::new(),
7982            unix_sockets_allowed: false,
7983            landlock_grants: Vec::new(),
7984            ..host_bounding_base()
7985        }
7986    }
7987
7988    /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
7989    /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
7990    /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
7991    fn workdir_resolution_error() -> String {
7992        let temp = tempfile::tempdir().unwrap();
7993        let missing = temp.path().join("does-not-exist-workdir");
7994        let marker = temp.path().join("never-spawned.marker");
7995        let mut command = std::process::Command::new("bash");
7996        command
7997            .arg("-c")
7998            .arg(format!("echo ran > '{}'", marker.display()));
7999
8000        let error = prepare_enforcement(
8001            &mut command,
8002            &kernel_full_empty_grants(),
8003            &missing,
8004            &missing,
8005        )
8006        .expect_err("an unresolvable workdir must fail before fork()");
8007        assert!(
8008            !marker.exists(),
8009            "prepare_enforcement must never spawn a subprocess: {error}"
8010        );
8011        error
8012    }
8013
8014    /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
8015    /// child-side failure active, asserts the target script never ran (marker absent), and returns
8016    /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
8017    fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
8018        child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
8019    }
8020
8021    /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
8022    /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
8023    /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
8024    /// identity out to the CLI instead of arriving as one more error string.
8025    fn child_setup_failure_typed<G>(
8026        enforcement: &ShellEnforcement,
8027        arm: impl FnOnce() -> G,
8028    ) -> crate::shell::ShellExecError {
8029        let temp = tempfile::tempdir().unwrap();
8030        let policy = CapabilityPolicy {
8031            shell_allow: vec!["bash".to_string()],
8032            ..CapabilityPolicy::default()
8033        };
8034        let marker = temp.path().join("spawned-anyway.marker");
8035        let script = format!("echo ran > '{}'", marker.display());
8036
8037        let _guard = arm();
8038        let error = crate::shell::execute_shell(
8039            "bash",
8040            &["-c", &script],
8041            &[],
8042            temp.path(),
8043            temp.path(),
8044            &policy,
8045            enforcement,
8046        )
8047        .expect_err("a forced child-side setup failure must make execute_shell return Err");
8048        assert!(
8049            !marker.exists(),
8050            "no subprocess may run when pre_exec setup fails: {error}"
8051        );
8052        error
8053    }
8054
8055    #[test]
8056    fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
8057        let error = workdir_resolution_error();
8058        assert!(
8059            error.contains("workdir") && error.contains("does-not-exist-workdir"),
8060            "the error must name the workdir path that failed to resolve: {error}"
8061        );
8062        assert!(
8063            !error.contains("os error 22"),
8064            "must not collapse to the bare EINVAL string: {error}"
8065        );
8066    }
8067
8068    #[test]
8069    fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
8070        if crate::network_namespace::skip_without_egress_namespace(
8071            "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
8072        ) {
8073            return;
8074        }
8075        let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
8076        assert!(
8077            error.contains("no_new_privs"),
8078            "the error must name the no_new_privs step specifically: {error}"
8079        );
8080        assert!(
8081            !error.contains("os error 22"),
8082            "must not collapse to the bare EINVAL string: {error}"
8083        );
8084    }
8085
8086    #[test]
8087    fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
8088        if crate::network_namespace::skip_without_egress_namespace(
8089            "pre_exec_landlock_failure_is_distinct_and_fails_closed",
8090        ) {
8091            return;
8092        }
8093        let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
8094        assert!(
8095            error.contains("landlock"),
8096            "the error must name the Landlock construction step specifically: {error}"
8097        );
8098        assert!(
8099            !error.contains("os error 22"),
8100            "must not collapse to the bare EINVAL string: {error}"
8101        );
8102    }
8103
8104    #[test]
8105    fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
8106        if crate::network_namespace::skip_without_egress_namespace(
8107            "pre_exec_setup_failures_produce_pairwise_distinct_messages",
8108        ) {
8109            return;
8110        }
8111        let workdir_msg = workdir_resolution_error();
8112        let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
8113        let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
8114
8115        let messages = [
8116            ("workdir", &workdir_msg),
8117            ("no_new_privs", &no_new_privs_msg),
8118            ("landlock", &landlock_msg),
8119        ];
8120        for (i, (left_name, left)) in messages.iter().enumerate() {
8121            for (right_name, right) in &messages[i + 1..] {
8122                assert_ne!(left, right, "{left_name} vs {right_name} must differ");
8123            }
8124        }
8125
8126        for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
8127            assert!(!message.is_empty(), "a distinct message must not be empty");
8128            assert!(
8129                !message.contains("os error 22"),
8130                "no failure may read as a bare EINVAL: {message}"
8131            );
8132        }
8133    }
8134
8135    /// The first half of the composed-root failure path: a `pre_exec`
8136    /// composed-root failure comes back out of `execute_shell` as the *typed*
8137    /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
8138    /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
8139    /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
8140    #[test]
8141    fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
8142        if crate::network_namespace::skip_without_egress_namespace(
8143            "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
8144        ) {
8145            return;
8146        }
8147        let error =
8148            child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
8149
8150        assert!(
8151            matches!(
8152                error,
8153                crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
8154            ),
8155            "a sealed-root diagnostic must survive as its own variant, not collapse into \
8156             ShellExecError::Failed: {error}"
8157        );
8158        let fatal = error
8159            .session_fatal()
8160            .expect("a composed-root failure must end the session, not just the tool call");
8161        assert!(
8162            matches!(
8163                fatal,
8164                crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
8165            ),
8166            "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
8167        );
8168        let rendered = error.to_string();
8169        assert!(
8170            rendered.contains("composed root") && rendered.contains("unshare"),
8171            "the message must name both the mechanism and the step that failed: {rendered}"
8172        );
8173        assert!(
8174            !rendered.contains("os error 22"),
8175            "must not collapse to the bare EINVAL string: {rendered}"
8176        );
8177    }
8178
8179    /// The contrast that makes the assertion above mean something: an equally fatal-looking
8180    /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
8181    /// in this path ends a session for a Landlock or `no_new_privs` problem.
8182    #[test]
8183    fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
8184        let landlock =
8185            child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
8186        assert!(
8187            landlock.session_fatal().is_none(),
8188            "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
8189        );
8190
8191        let no_new_privs = child_setup_failure_typed(
8192            &kernel_full_empty_grants(),
8193            ForceNoNewPrivsFailureGuard::new,
8194        );
8195        assert!(
8196            no_new_privs.session_fatal().is_none(),
8197            "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
8198        );
8199    }
8200}