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 no
24//!     Landlock ABI the ruleset is built at mediates an abstract-namespace socket.
25//!     `AF_NETLINK`/`AF_PACKET` are denied unconditionally; `AF_UNIX` is denied unless
26//!     `capabilities.network.unix_sockets` says 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 ruleset is
36//!     built at the kernel's Landlock ABI, capped at `LANDLOCK_TARGET_ABI` (v3), so on a 5.19+
37//!     kernel files move and hard-link between folders inside the workdir (`Refer`), and on 6.2+
38//!     truncation outside it is refused (`Truncate`). The workdir's own
39//!     grant deliberately withholds `MakeChar`/`MakeBlock`/`MakeSock`, and withholds `Execute`
40//!     unless `capabilities.filesystem.workdir_exec` is declared — see
41//!     `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC` and `linux_enforce::workdir_access_rights`.
42//!     Outside the workdir, alongside the derived
43//!     read+execute grants, a fixed three-device set is granted unconditionally — see
44//!     `CAPSULE_DEVICE_GRANTS`: `/dev/null` read+write (with the session's temporary and home
45//!     folders and any `read-write` host path, the only writable paths outside the workdir),
46//!     `/dev/zero` and `/dev/urandom` read-only, and no other device at all.
47//!
48//! Both Linux tiers additionally strip the forked child's **Linux capabilities** (bounding set,
49//! then permitted/effective/inheritable, then `no_new_privs`) before `execve()` — see
50//! `linux_enforce::drop_all_capabilities`. Landlock and the capability model are independent,
51//! both-must-allow gates: a root-uid capsule keeps `CAP_MKNOD` no matter what Landlock permits,
52//! so the Landlock narrowing above does not subsume this.
53//!
54//! Both are Linux-only. macOS (and any other non-Linux target) has no equivalent kernel
55//! primitive and permanently falls back to the existing, unmodified synthetic-HOME/env-
56//! stripping mechanism in `shell.rs` — see `EnforcementTier::EnvironmentOnly`.
57//!
58//! ## Four-tier model
59//!
60//! - `KernelSealed` (Linux, everything `KernelFull` has, plus a composed root the namespace probe
61//!   really built inside the capsule's network namespace — see [`crate::namespace_probe`]): the
62//!   subprocess tree additionally runs inside a private mount namespace pivoted onto a composed
63//!   root, so paths outside it are *absent* rather than denied. Landlock and seccomp still install,
64//!   inside the new root, as defence in depth — see [`crate::sealed`]. Only capsules that declare
65//!   `capabilities.containment: sealed` get this; see `applied_tier`.
66//! - `KernelFull` (Linux, Landlock ABI available — kernel 5.13+): seccomp syscall allowlist +
67//!   socket-domain denial + Landlock filesystem scoping, which is also what makes
68//!   `capabilities.shell.allow` enforceable (exec is a Landlock right, not a syscall filter).
69//! - `KernelSeccompOnly` (Linux, Landlock unavailable — kernel <5.13): seccomp syscall allowlist +
70//!   socket-domain denial only. Filesystem scope stays convention-only (`current_dir`) — a
71//!   documented gap, not a bug — and, since the exec supervisor was retired, so does
72//!   `shell.allow`: with no Landlock domain there is no kernel-level exec mediation on this tier
73//!   at all. `W-SEC-002` says so. The syscall allowlist and the socket-domain denial are identical
74//!   on both tiers: they are seccomp rules, so they do not depend on Landlock in any way.
75//! - `EnvironmentOnly` (macOS / any non-Linux target): no kernel primitive attempted at all.
76//!   Permanent, not a placeholder for a future slice.
77//!
78//! Tier detection is always a runtime capability probe (attempt Landlock ruleset
79//! construction, inspect the resulting `RulesetStatus`; fork a child and really call
80//! `unshare(CLONE_NEWUSER | CLONE_NEWNS)` followed by a `mount(2)`) — never a hardcoded
81//! kernel-version string parse, which is fragile against distro backports, and never a config
82//! file. Each reading is taken live, and a staged session takes exactly one for every consumer
83//! of that session to read; see [`HostProbe`].
84//!
85//! ## Fail-closed invariant
86//!
87//! If kernel enforcement setup fails unexpectedly on a Linux host (not the expected
88//! "Landlock unsupported, degrade to `KernelSeccompOnly`" signal, but something like seccomp
89//! filter install failing, or the session's network namespace failing to be created before
90//! spawn) — `prepare_enforcement` returns `Err` and `execute_shell` must not call `.spawn()` at
91//! all.
92//! There is no code path where a Linux host silently runs a shell subprocess with zero
93//! enforcement because setup failed.
94
95use std::net::IpAddr;
96use std::path::{Path, PathBuf};
97use std::sync::Arc;
98
99use murmur_artifact::security_warnings::{
100    security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
101};
102use murmur_artifact::InterpreterRuntimeGrant;
103
104use crate::types::CapabilityPolicy;
105
106/// Kernel-enforcement tier for the current host, in descending order of enforcement
107/// strength. Always host-probed at launch time — never sourced from the manifest.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) enum EnforcementTier {
110    /// Linux, and everything `KernelFull` has *plus* a private mount namespace pivoted onto a
111    /// composed root (see [`crate::sealed`]): the shipped AppArmor profile is loaded (or AppArmor
112    /// imposes no unprivileged-userns restriction), a real `unshare` + `mount` probe succeeded,
113    /// and the Landlock ABI is usable. Paths outside the composed root are *absent*, not merely
114    /// denied — and Landlock and seccomp still install inside it, as defence in depth.
115    ///
116    /// Ranked first because it is the strongest tier; the enum is documented weakest-last and
117    /// carries no `Ord`, so this position is documentation, not a comparison.
118    KernelSealed,
119    /// Linux, Landlock ABI available (kernel 5.13+): seccomp exec/network allowlisting
120    /// + Landlock filesystem scoping.
121    KernelFull,
122    /// Linux, Landlock unavailable (kernel <5.13): seccomp exec/network allowlisting
123    /// only. Filesystem scope stays convention-only (`current_dir`) — documented gap.
124    KernelSeccompOnly,
125    /// macOS (or any non-Linux target): no kernel sandboxing primitive exists. This is
126    /// PERMANENT, not a placeholder for a future slice.
127    EnvironmentOnly,
128    /// The operator chose to run the capsule unconfined (`ContainmentClass::Unconfined`), on any
129    /// host. Never a host reading: [`applied_tier`] is the only place it comes from. Subprocesses
130    /// get no network namespace, no `/tmp` mapping, no composed root, no capability drop, no
131    /// `no_new_privs`, no seccomp filter and no Landlock domain; they keep the fd-hygiene step,
132    /// their process group, and only the resource limits `capabilities.resources` declares.
133    Unconfined,
134}
135
136/// Pure decision: given whether the host is Linux, the outcome of a Landlock
137/// ruleset-restriction probe, and what [`crate::namespace_probe`] created, decide the tier. No
138/// syscalls here — fully unit-testable on any OS.
139///
140/// `KernelSealed` requires the whole conjunction — Linux, a fully enforced Landlock ruleset, and a
141/// sealed-root rehearsal that really built the composed root inside the capsule's network
142/// namespace. Any missing element falls back to the tier that element still supports; nothing
143/// here ever reports a mechanism the host did not demonstrate, and nothing here reads AppArmor.
144pub(crate) fn tier_from_probe(
145    is_linux: bool,
146    landlock_fully_enforced: Option<bool>,
147    namespaces: &crate::namespace_probe::NamespaceReading,
148) -> EnforcementTier {
149    if !is_linux {
150        return EnforcementTier::EnvironmentOnly;
151    }
152    match landlock_fully_enforced {
153        Some(true) if namespaces.sealed_root_created() => EnforcementTier::KernelSealed,
154        Some(true) => EnforcementTier::KernelFull,
155        Some(false) | None => EnforcementTier::KernelSeccompOnly,
156    }
157}
158
159/// The tier a session applies on a host whose tier is `host_tier`: never stronger than the host
160/// can back and never stronger than the capsule asked for.
161///
162/// A capsule declaring less than `sealed` on a sealed-capable host runs on
163/// [`EnforcementTier::KernelFull`]: the composed root would otherwise delete host paths its
164/// `interpreter_runtime` grants legitimately point at — a narrowing of `scoped`'s behaviour, not
165/// added security. The declared floor is honoured, not merely met.
166///
167/// Called only by [`crate::containment::ContainmentDecision::decide`], which reads the session's
168/// achieved class off the tier this returns, so the class a report names and the tier the
169/// session's commands run on are one decision.
170pub(crate) fn applied_tier(
171    host_tier: EnforcementTier,
172    declared: murmur_artifact::ContainmentClass,
173) -> EnforcementTier {
174    if declared == murmur_artifact::ContainmentClass::Unconfined {
175        return EnforcementTier::Unconfined;
176    }
177    match host_tier {
178        EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
179            EnforcementTier::KernelFull
180        }
181        other => other,
182    }
183}
184
185/// The host facts the tier decision is made from, taken together as one reading.
186///
187/// A staged session takes exactly one of these and every consumer of that session reads it — the
188/// containment-floor refusal, the `ScopeReport` written to the trace, the `/tmp` mapping, the
189/// `E-CAP-005` check at staging and at launch, and the tier [`ShellEnforcement::resolve`]
190/// installs. Two readings of a host that changed between them would let the trace describe a
191/// session that never ran under what it claims.
192#[derive(Debug, Clone)]
193pub struct HostProbe {
194    landlock_fully_enforced: Option<bool>,
195    /// The kernel's Landlock ABI, from `linux_enforce::kernel_landlock_abi`. `None` off Linux and
196    /// on a Linux kernel without Landlock.
197    landlock_kernel_abi: Option<u8>,
198    namespaces: crate::namespace_probe::NamespaceReading,
199}
200
201/// Probes taken since this process started. See [`HostProbe::probes_taken`].
202static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
203
204impl HostProbe {
205    /// Reads the host as it is right now: on Linux a real Landlock ruleset restriction attempt
206    /// (`RulesetStatus::FullyEnforced` → `Some(true)`, `PartiallyEnforced`/`NotEnforced` or any
207    /// construction error → `Some(false)`, no Landlock ABI → `None`) plus
208    /// [`crate::namespace_probe`]'s three attempts: the session's network namespace, a command's
209    /// private mount namespace inside it, and the composed root's nested namespace and pivot. Off
210    /// Linux it probes nothing and claims nothing.
211    ///
212    /// Taken once per staged session, not once per process. That is affordable because both
213    /// halves do their work in a forked child and leave the calling process exactly as they found
214    /// it — `linux_enforce::probe_landlock_full_access` forks so the runtime is never placed
215    /// into a Landlock domain by a capability check, and the namespace probe forks because
216    /// `unshare(CLONE_NEWUSER)` is irreversible for the task that calls it. Neither is
217    /// idempotency-sensitive, so a reading costs no more than the syscalls it makes: four
218    /// `fork`/`waitpid` pairs and three small `/proc` reads.
219    ///
220    /// The window this leaves open is one session wide. Between `runtime::stage_session` and the
221    /// last subprocess that session spawns the host is not re-read; a change inside that window is
222    /// caught by the mechanism installation itself failing closed, not by a second probe. A
223    /// process that stages repeatedly — `mur eval`, which stages once per dataset case — judges
224    /// each session against the host as it was when that session was staged.
225    pub fn probe() -> HostProbe {
226        PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
227        #[cfg(target_os = "linux")]
228        let probe = HostProbe {
229            landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
230            landlock_kernel_abi: linux_enforce::kernel_landlock_abi(),
231            namespaces: crate::namespace_probe::probe_namespaces(),
232        };
233        #[cfg(not(target_os = "linux"))]
234        let probe = HostProbe {
235            landlock_fully_enforced: None,
236            landlock_kernel_abi: None,
237            namespaces: crate::namespace_probe::probe_namespaces(),
238        };
239        probe
240    }
241
242    /// A probe value assembled from known halves, without touching the host. The seam the
243    /// fail-closed tests use to assert what an all-negative reading resolves to on any OS.
244    #[cfg(test)]
245    pub(crate) fn from_parts(
246        landlock_fully_enforced: Option<bool>,
247        namespaces: crate::namespace_probe::NamespaceReading,
248    ) -> HostProbe {
249        HostProbe {
250            landlock_fully_enforced,
251            landlock_kernel_abi: None,
252            namespaces,
253        }
254    }
255
256    /// This probe with its kernel Landlock ABI reading replaced. The seam the ABI tests use to
257    /// resolve a session as a kernel reporting `abi` would, on a host that reports another.
258    #[cfg(test)]
259    pub(crate) fn with_landlock_kernel_abi(self, abi: Option<u8>) -> HostProbe {
260        HostProbe {
261            landlock_kernel_abi: abi,
262            ..self
263        }
264    }
265
266    /// The tier this reading supports. `pub(crate)` because [`EnforcementTier`] is.
267    pub(crate) fn tier(&self) -> EnforcementTier {
268        tier_from_probe(
269            cfg!(target_os = "linux"),
270            self.landlock_fully_enforced,
271            &self.namespaces,
272        )
273    }
274
275    /// What the namespace probe attempted, and the readings it took with the attempts.
276    pub fn namespaces(&self) -> &crate::namespace_probe::NamespaceReading {
277        &self.namespaces
278    }
279
280    /// The `E-CAP-005` failure this reading's network attempt is, or `None` when it created the
281    /// namespace (and always off Linux).
282    pub fn egress_namespace_failure(
283        &self,
284    ) -> Option<crate::network_namespace::EgressNamespaceFailure> {
285        self.namespaces.egress_namespace_failure()
286    }
287
288    /// Whether this host can give a command a private mount namespace inside the session's user
289    /// namespace: the probe's mount attempt created one.
290    pub(crate) fn mount_namespace_usable(&self) -> bool {
291        cfg!(target_os = "linux") && self.namespaces.mount_namespace_created()
292    }
293
294    /// Why this reading cannot back `sealed`, or `None` when nothing keeps it below. `None`
295    /// exactly when [`Self::tier`] is `KernelSealed`.
296    pub fn sealed_shortfall(&self) -> Option<crate::sealed::SealedShortfall> {
297        crate::sealed::sealed_shortfall(
298            cfg!(target_os = "linux"),
299            self.landlock_fully_enforced == Some(true),
300            &self.namespaces,
301        )
302    }
303
304    /// Where this host's permission to create an unprivileged user namespace comes from, or
305    /// `None` off Linux, where the question has no answer rather than a negative one.
306    pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
307        if cfg!(target_os = "linux") {
308            Some(self.namespaces.userns_grant)
309        } else {
310            None
311        }
312    }
313
314    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock (off Linux
315    /// always). The ABI the session's ruleset is built at is [`ruleset_landlock_abi`] of this.
316    pub fn landlock_kernel_abi(&self) -> Option<u8> {
317        self.landlock_kernel_abi
318    }
319
320    /// How many probes this process has taken, counting from zero at startup and never
321    /// decreasing.
322    ///
323    /// The observability hook the freshness tests assert against — that staging twice advances it
324    /// by two, and that one `stage_session` advances it by one — and nothing else reads it. No
325    /// runtime decision depends on the count.
326    pub fn probes_taken() -> u64 {
327        PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
328    }
329}
330
331/// The tier this host can back, from a reading taken here. The seam the kernel tests resolve the
332/// host they run on through; a session reads its own [`HostProbe`].
333#[cfg(all(test, target_os = "linux"))]
334pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
335    HostProbe::probe().tier()
336}
337
338/// Why this host cannot back `sealed`, or `None` when nothing keeps it below, from a reading
339/// taken here.
340///
341/// Takes its own probe, as its neighbours do. What a session needs — that the refusal an operator
342/// sees and the tier the runtime installs cannot disagree about the host — is upheld by
343/// `runtime::stage_session` passing one [`HostProbe`] to both, not by this function.
344pub fn detect_sealed_shortfall() -> Option<crate::sealed::SealedShortfall> {
345    HostProbe::probe().sealed_shortfall()
346}
347
348/// Where this host's unprivileged-user-namespace permission comes from, or `None` off Linux, where
349/// the question has no answer rather than a negative one.
350///
351/// Takes its own probe, as its neighbours do; a session reads the provenance off its own
352/// [`HostProbe`] instead, so what it is shown and the tier it installs describe one host at one
353/// moment.
354pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
355    HostProbe::probe().userns_grant()
356}
357
358/// The kernel's Landlock ABI version, or `None` when there is no Landlock (off Linux always), from
359/// a reading taken here.
360///
361/// Takes its own probe, as its neighbours do; a session reads it off its own [`HostProbe`].
362pub fn detect_landlock_abi() -> Option<u8> {
363    HostProbe::probe().landlock_kernel_abi()
364}
365
366/// The highest Landlock ABI a capsule's ruleset is built at.
367///
368/// v2 adds `Refer` (moving and hard-linking between folders) and v3 adds `Truncate`, both granted
369/// on the workdir — see `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC`. v4 is not taken: it
370/// handles TCP `bind`/`connect`, and declaring those rights would deny every TCP connection the
371/// rules do not re-grant, which is the network namespace and egress proxy's boundary to draw. v5
372/// (`IoctlDev`) and v6 (abstract unix sockets and signals) each move a different boundary again.
373pub(crate) const LANDLOCK_TARGET_ABI: u8 = 3;
374
375/// The Landlock ABI this session's ruleset is built at: the kernel's, capped at
376/// [`LANDLOCK_TARGET_ABI`], on the two tiers that install a Landlock domain, and `None` on every
377/// other tier or when the kernel reports no Landlock.
378///
379/// Pure, so the ABI the trace records and the ABI the child builds at are the same function of
380/// the same [`HostProbe`] reading.
381pub(crate) fn ruleset_landlock_abi(kernel_abi: Option<u8>, tier: EnforcementTier) -> Option<u8> {
382    match tier {
383        EnforcementTier::KernelFull | EnforcementTier::KernelSealed => kernel_abi
384            .filter(|abi| *abi >= 1)
385            .map(|abi| abi.min(LANDLOCK_TARGET_ABI)),
386        EnforcementTier::KernelSeccompOnly
387        | EnforcementTier::EnvironmentOnly
388        | EnforcementTier::Unconfined => None,
389    }
390}
391
392/// The lines `mur doctor` prints under its `Landlock` heading for a kernel reporting
393/// `kernel_abi`: what the kernel offers, what the runtime builds at, and what a capsule loses on
394/// this host below [`LANDLOCK_TARGET_ABI`].
395///
396/// Pure and cross-platform, so a test can check the exact words for a kernel this host is not.
397pub fn landlock_doctor_lines(kernel_abi: Option<u8>) -> Vec<String> {
398    if !cfg!(target_os = "linux") {
399        return vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()];
400    }
401    let Some(kernel) = kernel_abi.filter(|abi| *abi >= 1) else {
402        return vec![
403            "kernel ABI: none (no Landlock on this host)".to_string(),
404            "ruleset ABI: n/a (no Landlock domain is installed)".to_string(),
405        ];
406    };
407    let built = kernel.min(LANDLOCK_TARGET_ABI);
408    let mut lines = vec![
409        format!("kernel ABI: {kernel}"),
410        format!("ruleset ABI: {built} (the kernel's, capped at {LANDLOCK_TARGET_ABI})"),
411    ];
412    if built < 2 {
413        lines.push(
414            "moving or hard-linking a file between folders inside the work folder fails with \
415             EXDEV (Invalid cross-device link) on this host; Linux 5.19 (Landlock ABI v2) \
416             permits it. Tools that fall back to copy-and-delete (mv, pip, git) still work; uv \
417             and other tools that rename atomically between folders do not."
418                .to_string(),
419        );
420    }
421    if built < 3 {
422        lines.push(
423            "truncate(2) on a file outside the work folder is not mediated below Landlock ABI v3 \
424             (Linux 6.2)."
425                .to_string(),
426        );
427    }
428    lines
429}
430
431/// Resolves every host in `network_allow` (via `crate::network_policy::parse_network_allow_rules`,
432/// the same parser already used for the WASI-HTTP-facing allowlist) to concrete IP addresses
433/// via DNS, once. Returns the deduplicated union of all resolved addresses across all allowed
434/// hosts.
435///
436/// Hosts that fail to resolve are silently skipped, matching `resolve_exec_allowlist`'s precedent
437/// below: the resolved set feeds *only* the kernel-level (Landlock/seccomp) allow set for
438/// `shell.allow` subprocesses, so a host contributing zero IPs merely *shrinks* that set (deny
439/// direction, fail-closed). A DNS miss must not be fatal — resolution happens unconditionally at
440/// staging time, before any WASM loads and regardless of whether the capsule declares
441/// `shell.allow` at all, so hard-failing here turned any transient resolver outage or
442/// non-resolving allowlist host into an E-IO-003 that blocked the whole run.
443///
444/// This does not widen what the capsule may reach: the WASM component's own outbound WASI-HTTP
445/// calls are gated separately by `network_policy::NetworkAllowRule::matches`, a host-pattern
446/// match that never consults DNS. Malformed host *syntax* is still a hard error — it is caught by
447/// `parse_network_allow_rules` above, before this loop.
448pub(crate) fn resolve_network_allowlist_ips(
449    network_allow: &[String],
450) -> Result<Vec<IpAddr>, String> {
451    let rules = crate::network_policy::parse_network_allow_rules(network_allow)
452        .map_err(|error| error.to_string())?;
453
454    let mut ips = std::collections::BTreeSet::new();
455    for rule in &rules {
456        let host = rule.host.as_str();
457        match crate::dns_resolver::resolve(host) {
458            crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
459            crate::dns_resolver::Resolution::DoesNotExist => {}
460            // Skipped like any other host that contributed no address, but said out loud: a
461            // resolver that did not answer is a transient condition an operator can act on,
462            // where a name that does not exist is a manifest to correct.
463            crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
464                "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
465                 resolved at launch: {reason} (a subprocess reaching it by literal address is \
466                 denied for this run)"
467            ),
468        }
469    }
470
471    Ok(ips.into_iter().collect())
472}
473
474/// Resolves each `capabilities.shell.allow` entry to the canonical filesystem path(s) of the
475/// binary it names, once, at launch time. Entries containing a path separator are
476/// canonicalized directly; bare names (the common case, per the manifest schema's
477/// "bare binary name" rule) are looked up in the host `PATH` the way `execvp` would, then
478/// canonicalized (resolving symlinks, `..`, etc.).
479///
480/// Entries that resolve to nothing (binary not installed) are silently skipped — that only
481/// *shrinks* the kernel-level allow set (deny direction, fail-closed) and matches reality:
482/// an exec of a binary that does not exist at launch fails anyway, and a binary *created
483/// after launch* under an allowlisted name (the attack this exists to stop) must not become
484/// allowlisted by virtue of its name.
485///
486/// This canonical set — not the raw name strings — is what `resolve_landlock_grants` derives the
487/// narrow read+execute Landlock grants from, so `cp /usr/bin/nc ./bash && ./bash` is denied: the
488/// copy lives under the workdir, which carries no `Execute` right by default, and the grant that
489/// does carry one names the launch-time `bash`'s own inode, not a basename.
490pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
491    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
492        .map(|path| std::env::split_paths(&path).collect())
493        .unwrap_or_default();
494    resolve_exec_allowlist_in(shell_allow, &path_dirs)
495}
496
497/// Testable core of `resolve_exec_allowlist`, with the `PATH` directory list injected. Every
498/// entry is a bare binary name (the manifest refuses any other, `E-MAN-003`), and every
499/// executable `PATH` match is included (not just the first): all of them are binaries the
500/// operator's launch-time `PATH` exposes under that allowlisted name, and on merged-`/usr` distros
501/// `/bin/x` and `/usr/bin/x` canonicalize to the same entry anyway.
502pub(crate) fn resolve_exec_allowlist_in(
503    shell_allow: &[String],
504    path_dirs: &[PathBuf],
505) -> Vec<PathBuf> {
506    let mut resolved = std::collections::BTreeSet::new();
507    for entry in shell_allow {
508        if entry.is_empty() {
509            continue;
510        }
511        for dir in path_dirs {
512            let candidate = dir.join(entry);
513            if is_executable_file(&candidate) {
514                if let Ok(canonical) = std::fs::canonicalize(&candidate) {
515                    resolved.insert(canonical);
516                }
517            }
518        }
519    }
520    resolved.into_iter().collect()
521}
522
523/// Resolves the program name a shell tool invoked to the canonical filesystem path of the
524/// binary that `execvp` would actually run, for reporting purposes: `shell-event.binary`
525/// on the hook side and the `binary` key of `trace.jsonl`'s `shell` record.
526///
527/// Unlike [`resolve_exec_allowlist`] — which collects *every* `PATH` match because each one needs
528/// its own Landlock grant — this returns the **first** match, which is the one `execvp` picks and
529/// therefore the one that ran. Resolution rules are otherwise identical (same `PATH` source, same
530/// [`is_executable_file`] test, same `canonicalize`), so a reported path is always a member of the
531/// set the Landlock grants were derived from.
532///
533/// A name that resolves to nothing falls back to the bare invoked name, unchanged — never
534/// an error, never a panic, matching `resolve_exec_allowlist`'s "entries that resolve to
535/// nothing are silently skipped" precedent. Observability must not be able to fail a shell
536/// call that the OS is perfectly willing to run.
537pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
538    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
539        .map(|path| std::env::split_paths(&path).collect())
540        .unwrap_or_default();
541    resolve_invoked_binary_path_in(binary, &path_dirs)
542}
543
544/// Testable core of [`resolve_invoked_binary_path`], with the `PATH` directory list
545/// injected. `pub(crate)` because [`crate::reachability`] needs the same first-match resolution
546/// against an *injected* directory list — its own unit tests point `PATH` at a temp directory of
547/// synthetic script fixtures rather than at whatever the machine running the suite has installed.
548pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
549    let fallback = || binary.to_string();
550    if binary.is_empty() {
551        return fallback();
552    }
553    if binary.contains('/') {
554        return match std::fs::canonicalize(binary) {
555            Ok(canonical) => canonical.to_string_lossy().into_owned(),
556            Err(_) => fallback(),
557        };
558    }
559    for dir in path_dirs {
560        let candidate = dir.join(binary);
561        if is_executable_file(&candidate) {
562            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
563                return canonical.to_string_lossy().into_owned();
564            }
565        }
566    }
567    fallback()
568}
569
570/// The first executable named `binary` on this process's `PATH`, or `None` when there is none.
571///
572/// First match wins, which is the one `execvp` would run — the same rule
573/// [`resolve_invoked_binary_path_in`] reports under, so a caller naming a binary and a caller
574/// reporting one cannot disagree about which file `PATH` means. The path is returned as found,
575/// not canonicalized: callers here name a binary to a person rather than derive a grant from it.
576pub fn find_on_path(binary: &str) -> Option<PathBuf> {
577    std::env::split_paths(&std::env::var_os("PATH")?)
578        .map(|dir| dir.join(binary))
579        .find(|candidate| is_executable_file(candidate))
580}
581
582pub(crate) fn is_executable_file(path: &Path) -> bool {
583    use std::os::unix::fs::PermissionsExt;
584    std::fs::metadata(path)
585        .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
586        .unwrap_or(false)
587}
588
589// ---- derived Landlock read+execute grant set ----
590//
591// On `KernelFull`, Landlock scopes the shell subprocess tree to the workdir. The workdir rule
592// grants `linux_enforce::workdir_access_rights` (every right of the ABI the ruleset is built at
593// except device-node and unix-socket creation, and `Execute` unless `workdir_exec`), but — once
594// `restrict_self()` lands — `Execute`/`ReadFile` are then denied on every path *outside* the
595// workdir, including the `shell.allow` binaries themselves, their ELF interpreter (dynamic loader),
596// and every shared library they pull in. That silently defeats the seccomp exec-allowlist: each
597// allowlisted `execve` fails with EACCES before it runs.
598//
599// The functions below derive the exact extra paths that must get a narrow read+execute grant
600// outside the workdir: the canonical `shell.allow` binaries (already resolved by
601// `resolve_exec_allowlist`), each one's `PT_INTERP`, and the transitive closure of the shared
602// libraries their ELF `DT_NEEDED` entries name (resolved via `DT_RPATH`/`DT_RUNPATH` first, then a
603// fixed set of standard library directories). Nothing broader — no directory is granted wholesale.
604//
605// All of this is *parsing and filesystem resolution*, so it runs in the parent at launch time
606// (inside `ShellEnforcement::resolve`) and the resulting `Vec<PathBuf>` is threaded into the
607// forked child's `pre_exec`, which only opens each path and adds a Landlock rule (no parsing in
608// the async-signal-safe window). Every resolution step is shrink-not-fail, matching
609// `resolve_exec_allowlist`: an entry that does not exist, does not parse as ELF, or names a
610// soname that does not resolve simply contributes nothing further — it never errors out.
611
612/// Standard Linux shared-library search directories used as the fallback resolution set for a
613/// `DT_NEEDED` soname when the binary declares no `DT_RPATH`/`DT_RUNPATH` (or it doesn't match).
614/// Covers both supported platform targets (`linux/x86_64` and `linux/aarch64`), including the
615/// Debian/Ubuntu multiarch-triplet subdirectories. Extend here for a platform this list does not
616/// cover — it is the single source of truth.
617pub(crate) const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
618    "/lib",
619    "/lib64",
620    "/usr/lib",
621    "/usr/lib64",
622    "/lib/x86_64-linux-gnu",
623    "/usr/lib/x86_64-linux-gnu",
624    "/lib/aarch64-linux-gnu",
625    "/usr/lib/aarch64-linux-gnu",
626];
627
628/// The subset of an ELF binary's dynamic-linking metadata that decides which extra files the
629/// dynamic loader will touch at load time: the interpreter path (`PT_INTERP`), the direct
630/// shared-library dependencies (`DT_NEEDED` sonames), and the runtime library search paths
631/// (`DT_RPATH`/`DT_RUNPATH`, already split on `:`, `$ORIGIN` left unexpanded here).
632#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
633#[serde(deny_unknown_fields)]
634pub(crate) struct ElfDependencies {
635    pub(crate) interp: Option<String>,
636    pub(crate) needed: Vec<String>,
637    pub(crate) runpaths: Vec<String>,
638}
639
640/// An ELF image the parsers below read by position: its length, and the bytes at an offset. Each
641/// parser asks only for the headers and tables it decodes, so an image read from a file costs a
642/// few `pread`s, never the whole file.
643pub(crate) trait ElfSource {
644    /// The image's length in bytes.
645    fn size(&self) -> u64;
646
647    /// The `len` bytes at `offset`, or `None` when they do not lie wholly inside the image or
648    /// cannot be read.
649    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>>;
650}
651
652/// An image already in memory.
653impl ElfSource for [u8] {
654    fn size(&self) -> u64 {
655        self.len() as u64
656    }
657
658    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
659        let start = usize::try_from(offset).ok()?;
660        let end = start.checked_add(usize::try_from(len).ok()?)?;
661        self.get(start..end).map(std::borrow::Cow::Borrowed)
662    }
663}
664
665/// An image read from an open file with `pread`, its length the file's size when it was opened.
666pub(crate) struct ElfFile {
667    file: std::fs::File,
668    metadata: std::fs::Metadata,
669}
670
671impl ElfFile {
672    /// Opens the host file at `path` for reading.
673    pub(crate) fn open_host(path: &Path) -> std::io::Result<Self> {
674        let file = std::fs::File::open(path)?;
675        let metadata = file.metadata()?;
676        Ok(Self { file, metadata })
677    }
678
679    /// The open file's metadata, read from the handle when it was opened.
680    #[cfg(target_os = "linux")]
681    pub(crate) fn metadata(&self) -> &std::fs::Metadata {
682        &self.metadata
683    }
684}
685
686impl ElfSource for ElfFile {
687    fn size(&self) -> u64 {
688        self.metadata.len()
689    }
690
691    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
692        use std::os::unix::fs::FileExt;
693
694        if offset.checked_add(len)? > self.size() {
695            return None;
696        }
697        let mut bytes = vec![0; usize::try_from(len).ok()?];
698        self.file.read_exact_at(&mut bytes, offset).ok()?;
699        Some(std::borrow::Cow::Owned(bytes))
700    }
701}
702
703fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
704    let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
705    Some(if le {
706        u16::from_le_bytes(raw)
707    } else {
708        u16::from_be_bytes(raw)
709    })
710}
711
712fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
713    let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
714    Some(if le {
715        u32::from_le_bytes(raw)
716    } else {
717        u32::from_be_bytes(raw)
718    })
719}
720
721fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
722    let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
723    Some(if le {
724        u64::from_le_bytes(raw)
725    } else {
726        u64::from_be_bytes(raw)
727    })
728}
729
730/// Whether the ELF identification at the start of `header` is ELF64 little-endian (`true`) or
731/// big-endian (`false`); `None` for anything else.
732fn elf64_little_endian(header: &[u8]) -> Option<bool> {
733    // EI_CLASS: 2 == ELFCLASS64. EI_DATA: 1 == little-endian, 2 == big-endian.
734    if header.get(0..4)? != b"\x7fELF" || *header.get(4)? != 2 {
735        return None;
736    }
737    match header.get(5)? {
738        1 => Some(true),
739        2 => Some(false),
740        _ => None,
741    }
742}
743
744/// The `len` bytes at `offset`, or as many of them as `image` holds; empty when `offset` is at or
745/// past its end.
746fn read_clamped<S: ElfSource + ?Sized>(
747    image: &S,
748    offset: u64,
749    len: u64,
750) -> Option<std::borrow::Cow<'_, [u8]>> {
751    let available = image.size().saturating_sub(offset).min(len);
752    if available == 0 {
753        return Some(std::borrow::Cow::Borrowed(&[]));
754    }
755    image.read_at(offset, available)
756}
757
758/// Reads a NUL-terminated string starting at the beginning of `region` (used for both the
759/// `PT_INTERP` segment and dynamic-string-table entries).
760fn cstr_at_start(region: &[u8]) -> String {
761    let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
762    String::from_utf8_lossy(&region[..nul]).into_owned()
763}
764
765/// Maps a virtual address into a file offset using the ELF's `PT_LOAD` segments — required
766/// because `DT_STRTAB` records the string table's virtual address, not its file offset.
767fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
768    for &(seg_vaddr, seg_off, seg_filesz) in loads {
769        if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
770            return Some(seg_off + (vaddr - seg_vaddr));
771        }
772    }
773    None
774}
775
776/// Pure ELF64 parser: extracts the dynamic-linking metadata that determines which extra files the
777/// loader touches. Returns `None` for anything that is not a well-formed ELF64 image (bad magic,
778/// 32-bit, truncated headers) — a non-ELF or unparseable binary simply contributes nothing to the
779/// grant set (shrink-not-fail). Deliberately hand-rolled (no crate dependency) so it is fully
780/// unit-testable off-Linux with synthetic in-memory fixtures.
781///
782/// Reads only the 64-byte header, the program header table, the `PT_INTERP` bytes, the
783/// `PT_DYNAMIC` array and the dynamic string table. Every read is bounded by the image's length,
784/// so the answer is the same whether `image` is in memory or a file read by position.
785///
786/// Only ELF64 is parsed: both supported Linux targets (`x86_64`, `aarch64`) are 64-bit, so a
787/// 32-bit image is not a binary this runtime would exec and is treated as unparseable.
788pub(crate) fn parse_elf_dependencies<S: ElfSource + ?Sized>(image: &S) -> Option<ElfDependencies> {
789    // e_ident (16) + fixed ELF64 header fields extend to offset 64.
790    let header = image.read_at(0, 64)?;
791    let le = elf64_little_endian(&header)?;
792
793    let e_phoff = read_u64(&header, 32, le)?;
794    let e_phentsize = u64::from(read_u16(&header, 54, le)?);
795    let e_phnum = u64::from(read_u16(&header, 56, le)?);
796    // Each Elf64_Phdr is 56 bytes; a smaller entsize is not this layout.
797    if e_phentsize < 56 {
798        return None;
799    }
800
801    let mut interp: Option<String> = None;
802    let mut dynamic_region: Option<(u64, u64)> = None; // (file offset, size)
803    let mut loads: Vec<(u64, u64, u64)> = Vec::new(); // (p_vaddr, p_offset, p_filesz)
804
805    // Every field read below lies in the first 40 bytes of an entry, so the table ends 40 bytes
806    // into its last entry.
807    let table = match e_phnum.checked_sub(1) {
808        None => std::borrow::Cow::Borrowed(&[][..]),
809        Some(last) => image.read_at(e_phoff, last.checked_mul(e_phentsize)?.checked_add(40)?)?,
810    };
811    for i in 0..e_phnum {
812        let base = usize::try_from(i * e_phentsize).ok()?;
813        let p_type = read_u32(&table, base, le)?;
814        let p_offset = read_u64(&table, base + 8, le)?;
815        let p_vaddr = read_u64(&table, base + 16, le)?;
816        let p_filesz = read_u64(&table, base + 32, le)?;
817        match p_type {
818            1 => loads.push((p_vaddr, p_offset, p_filesz)), // PT_LOAD
819            2 => dynamic_region = Some((p_offset, p_filesz)), // PT_DYNAMIC
820            3 => {
821                // PT_INTERP: a NUL-terminated interpreter path in the file image.
822                interp = Some(cstr_at_start(&image.read_at(p_offset, p_filesz)?));
823            }
824            _ => {}
825        }
826    }
827
828    // A static binary (no PT_DYNAMIC) contributes only itself — no interp, no needed libs.
829    let Some((dyn_off, dyn_size)) = dynamic_region else {
830        return Some(ElfDependencies {
831            interp,
832            needed: Vec::new(),
833            runpaths: Vec::new(),
834        });
835    };
836
837    let mut needed_offsets: Vec<u64> = Vec::new();
838    let mut runpath_offsets: Vec<u64> = Vec::new();
839    let mut strtab_vaddr: Option<u64> = None;
840    let mut strsz: Option<u64> = None;
841
842    dyn_off.checked_add(dyn_size)?;
843    // Entries after `DT_NULL` are never read, so a segment that runs past the end of the file is
844    // read as far as the file goes, and an entry before `DT_NULL` beyond that fails the parse.
845    let dynamic = read_clamped(image, dyn_off, dyn_size)?;
846    let mut off = 0u64;
847    while off + 16 <= dyn_size {
848        let entry = dynamic.get(usize::try_from(off).ok()?..usize::try_from(off + 16).ok()?)?;
849        let d_tag = read_u64(entry, 0, le)?;
850        let d_val = read_u64(entry, 8, le)?;
851        match d_tag {
852            0 => break,                             // DT_NULL — end of dynamic array
853            1 => needed_offsets.push(d_val),        // DT_NEEDED
854            5 => strtab_vaddr = Some(d_val),        // DT_STRTAB (virtual address)
855            10 => strsz = Some(d_val),              // DT_STRSZ
856            15 | 29 => runpath_offsets.push(d_val), // DT_RPATH | DT_RUNPATH
857            _ => {}
858        }
859        off += 16;
860    }
861
862    let mut needed = Vec::new();
863    let mut runpaths = Vec::new();
864    if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
865        if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
866            // Prefer the declared string-table bounds, but tolerate an over-long DT_STRSZ by
867            // falling back to the rest of the file rather than dropping every soname.
868            let strtab = (strtab_off <= image.size())
869                .then(|| read_clamped(image, strtab_off, strsz))
870                .flatten();
871            if let Some(strtab) = strtab {
872                for offset in needed_offsets {
873                    if let Some(region) = strtab.get(offset as usize..) {
874                        needed.push(cstr_at_start(region));
875                    }
876                }
877                for offset in runpath_offsets {
878                    if let Some(region) = strtab.get(offset as usize..) {
879                        for part in cstr_at_start(region).split(':') {
880                            if !part.is_empty() {
881                                runpaths.push(part.to_string());
882                            }
883                        }
884                    }
885                }
886            }
887        }
888    }
889
890    Some(ElfDependencies {
891        interp,
892        needed,
893        runpaths,
894    })
895}
896
897/// Whether `image` is an ELF64 shared object (`e_type` `ET_DYN`), in either byte order. Reads only
898/// the first 18 bytes, so a caller sorting a directory reads no more of each file than that. A
899/// position-independent executable is `ET_DYN` too and answers `true`.
900#[cfg(target_os = "linux")]
901pub(crate) fn elf_is_shared_object<S: ElfSource + ?Sized>(image: &S) -> bool {
902    let Some(head) = image.read_at(0, 18) else {
903        return false;
904    };
905    elf64_little_endian(&head).is_some_and(|le| read_u16(&head, 16, le) == Some(3))
906}
907
908/// The name of every entry in an ELF64 image's dynamic symbol table (`.dynsym`), defined and
909/// undefined alike, in table order. `None` for anything that is not a well-formed ELF64 image or
910/// has no `.dynsym` section.
911///
912/// Reads the header, the section headers up to the `SHT_DYNSYM` one, that section and the string
913/// table its `sh_link` names, and nothing else of the image. A symbol the binary imports from a
914/// shared library is listed here as well as one it defines, so an interpreter built against
915/// `libpython` or `libperl` names its entry symbol the same way a statically linked one does. A
916/// binary stripped of its section headers names nothing.
917#[cfg(target_os = "linux")]
918pub(crate) fn elf_dynamic_symbol_names<S: ElfSource + ?Sized>(image: &S) -> Option<Vec<String>> {
919    let header = image.read_at(0, 64)?;
920    let le = elf64_little_endian(&header)?;
921    let e_shoff = read_u64(&header, 40, le)?;
922    let e_shentsize = u64::from(read_u16(&header, 58, le)?);
923    let e_shnum = u64::from(read_u16(&header, 60, le)?);
924    // Each Elf64_Shdr is 64 bytes; a smaller entsize is not this layout.
925    if e_shentsize < 64 {
926        return None;
927    }
928    // (sh_type, sh_offset, sh_size, sh_link, sh_entsize) of section `index`.
929    let section = |index: u64| -> Option<(u32, u64, u64, u64, u64)> {
930        let base = e_shoff.checked_add(index.checked_mul(e_shentsize)?)?;
931        let entry = image.read_at(base, 64)?;
932        Some((
933            read_u32(&entry, 4, le)?,
934            read_u64(&entry, 24, le)?,
935            read_u64(&entry, 32, le)?,
936            u64::from(read_u32(&entry, 40, le)?),
937            read_u64(&entry, 56, le)?,
938        ))
939    };
940    // SHT_DYNSYM == 11.
941    let (_, sym_off, sym_size, strtab_index, sym_entsize) = (0..e_shnum)
942        .filter_map(section)
943        .find(|(sh_type, ..)| *sh_type == 11)?;
944    // Each Elf64_Sym is 24 bytes.
945    if sym_entsize < 24 {
946        return None;
947    }
948    let (_, str_off, str_size, _, _) = section(strtab_index)?;
949    let strtab = image.read_at(str_off, str_size)?;
950    let symbols = image.read_at(sym_off, sym_size)?;
951    let mut names = Vec::new();
952    for symbol in symbols.chunks_exact(usize::try_from(sym_entsize).ok()?) {
953        let st_name = read_u32(symbol, 0, le)? as usize;
954        if st_name == 0 {
955            continue;
956        }
957        if let Some(region) = strtab.get(st_name..) {
958            names.push(cstr_at_start(region));
959        }
960    }
961    Some(names)
962}
963
964/// Expands a leading/embedded `$ORIGIN` (or `${ORIGIN}`) in an rpath entry to the directory of the
965/// binary that declared it — the dynamic loader's own rule for `DT_RUNPATH`/`DT_RPATH`.
966fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
967    if rpath.contains("$ORIGIN") {
968        let origin = origin_dir.to_string_lossy();
969        PathBuf::from(
970            rpath
971                .replace("${ORIGIN}", &origin)
972                .replace("$ORIGIN", &origin),
973        )
974    } else {
975        PathBuf::from(rpath)
976    }
977}
978
979/// Resolves one `DT_NEEDED` soname to a canonical file path, mirroring the loader's search order:
980/// a soname containing a `/` is taken as a path (absolute, or relative to the binary's directory);
981/// otherwise the binary's own `DT_RUNPATH`/`DT_RPATH` entries (with `$ORIGIN` expanded) are tried
982/// first, then the standard `search_dirs`. Returns `None` (shrink-not-fail) if nothing matches.
983pub(crate) fn resolve_soname(
984    soname: &str,
985    origin_dir: &Path,
986    runpaths: &[String],
987    search_dirs: &[PathBuf],
988) -> Option<PathBuf> {
989    if soname.is_empty() {
990        return None;
991    }
992    if soname.contains('/') {
993        let candidate = if Path::new(soname).is_absolute() {
994            PathBuf::from(soname)
995        } else {
996            origin_dir.join(soname)
997        };
998        return std::fs::canonicalize(&candidate).ok();
999    }
1000    for rpath in runpaths {
1001        let candidate = expand_origin(rpath, origin_dir).join(soname);
1002        if candidate.is_file() {
1003            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1004                return Some(canonical);
1005            }
1006        }
1007    }
1008    for dir in search_dirs {
1009        let candidate = dir.join(soname);
1010        if candidate.is_file() {
1011            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1012                return Some(canonical);
1013            }
1014        }
1015    }
1016    None
1017}
1018
1019/// Derives the full set of canonical file paths that need a narrow read+execute Landlock grant
1020/// *outside* the workdir so the `shell.allow` binaries can actually run: each allowlisted binary,
1021/// its ELF interpreter, and the transitive closure of its `DT_NEEDED` shared libraries. Uses the
1022/// standard [`DEFAULT_LIBRARY_SEARCH_DIRS`] as the soname fallback search set.
1023///
1024/// Every step is shrink-not-fail (a binary that can't be read/parsed, or a soname that doesn't
1025/// resolve, contributes nothing further); the returned set is deduplicated and canonical. Runs in
1026/// the parent at launch time — never in the forked child's `pre_exec`.
1027pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
1028    let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1029        .iter()
1030        .map(PathBuf::from)
1031        .collect();
1032    resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
1033}
1034
1035/// Testable core of [`resolve_landlock_grants`], with the library search directories injected so
1036/// unit tests can point it at synthetic fixtures instead of the real system directories.
1037fn resolve_landlock_grants_in(
1038    exec_allow_paths: &[PathBuf],
1039    search_dirs: &[PathBuf],
1040) -> Vec<PathBuf> {
1041    let Ok(grants) = walk_dependency_closure(
1042        exec_allow_paths,
1043        search_dirs,
1044        &[],
1045        |path| -> Result<_, std::convert::Infallible> { Ok(read_elf_dependencies(path)) },
1046    );
1047    grants
1048}
1049
1050/// The dependencies of the ELF image at `path`, read through [`ElfFile`]. `None` when it cannot
1051/// be opened or does not parse.
1052fn read_elf_dependencies(path: &Path) -> Option<ElfDependencies> {
1053    parse_elf_dependencies(&ElfFile::open_host(path).ok()?)
1054}
1055
1056/// The walk behind [`resolve_landlock_grants_in`], with each visited file's dependencies taken
1057/// from `dependencies`, which is asked once per visited path. An error from it ends the walk with
1058/// that error. A path under one of the canonical `excluded` roots is neither granted nor read for
1059/// further dependencies.
1060pub(crate) fn walk_dependency_closure<E>(
1061    exec_allow_paths: &[PathBuf],
1062    search_dirs: &[PathBuf],
1063    excluded: &[PathBuf],
1064    mut dependencies: impl FnMut(&Path) -> Result<Option<ElfDependencies>, E>,
1065) -> Result<Vec<PathBuf>, E> {
1066    let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1067    let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1068    let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
1069
1070    for path in exec_allow_paths {
1071        // The allowlist paths are already canonical/existing (from `resolve_exec_allowlist`), but
1072        // canonicalize defensively — this both makes dedup against interp/library paths exact and
1073        // drops any seed that no longer exists (shrink-not-fail: a vanished binary contributes
1074        // nothing, and could not be exec'd anyway).
1075        if let Ok(canonical) = std::fs::canonicalize(path) {
1076            queue.push_back(canonical);
1077        }
1078    }
1079
1080    while let Some(path) = queue.pop_front() {
1081        if excluded.iter().any(|root| path.starts_with(root)) {
1082            continue;
1083        }
1084        if !visited.insert(path.clone()) {
1085            continue;
1086        }
1087        // The binary/library itself is granted. If it lives inside the workdir, the workdir's own
1088        // full-access rule already covers it and this extra read+execute rule is simply redundant.
1089        grants.insert(path.clone());
1090
1091        let Some(deps) = dependencies(&path)? else {
1092            continue;
1093        };
1094        let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
1095
1096        if let Some(interp) = &deps.interp {
1097            if let Ok(canonical) = std::fs::canonicalize(interp) {
1098                queue.push_back(canonical);
1099            }
1100        }
1101        for soname in &deps.needed {
1102            if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
1103            {
1104                queue.push_back(resolved);
1105            }
1106        }
1107    }
1108
1109    Ok(grants.into_iter().collect())
1110}
1111
1112/// One resolved Landlock filesystem grant outside the workdir: a canonical path plus the two
1113/// independent bits that decide the rule's access set.
1114///
1115/// `list_dir` decides enumerability (`ReadDir`):
1116///
1117///   - `false` → no `getdents64` on the granted directory itself. Landlock's read rights apply to
1118///     the whole subtree beneath a granted directory, so a file *inside* it can still be opened by
1119///     its exact name — the listing is what is denied. This is what every derived
1120///     `DT_NEEDED`-closure grant gets — they are individual files, where `ReadDir` was always a
1121///     no-op anyway (Landlock's `ReadDir` only has meaning on a directory inode).
1122///   - `true` → adds enumerability, which a path-based interpreter's import machinery needs on
1123///     each `sys.path` entry (CPython's `FileFinder` `listdir`-caches each one). Set by an author
1124///     writing `list_dir: true` next to a specific `interpreter_runtime` directory, on each
1125///     directory a recognised interpreter reports on its own import path
1126///     ([`resolve_derived_interpreter_grants`]), and by the whole-tree resolvers
1127///     ([`resolve_staged_runtime_landlock_grants`], [`resolve_sealed_runtime_landlock_grants`]
1128///     and, per entry, the directory half of [`resolve_sealed_etc_landlock_grants`]) where a tree
1129///     is walked by definition — never inferred from anything else, and never applied to an
1130///     ancestor or sibling of a granted directory.
1131///
1132/// `ReadDir` is meaningful only on a directory, and the kernel enforces that: `landlock_add_rule`
1133/// returns `EINVAL` for a directory-only right on a non-directory. `open_landlock_fds` therefore
1134/// narrows `list_dir` to `false` for any grant whose fd does not `fstat` as a directory, so this
1135/// field is a request rather than a promise.
1136///
1137/// `executable` decides `Execute`, which is this runtime's exec allowlist: a path without an
1138/// `Execute` rule cannot be `execve`d, and a path with one can. This is the canonical statement of
1139/// that rule; the grant resolvers below cross-reference it rather than restating it.
1140///
1141///   - `true` → `Execute + ReadFile`, for a path the manifest asked to *run*: a `shell.allow`
1142///     binary, its `DT_NEEDED` closure, and the interpreter/staged trees an author named.
1143///   - `false` → `ReadFile` only (readable, and with `list_dir` enumerable, but not runnable).
1144///     For a grant the *tier* issues rather than the manifest: see
1145///     [`resolve_sealed_runtime_landlock_grants`], where granting `Execute` over `/usr`, `/bin`
1146///     and `/sbin` wholesale would turn `shell.allow` into a no-op on `sealed`, and
1147///     [`resolve_sealed_etc_landlock_grants`], where `/etc/alternatives` is a directory of
1148///     symlinks into exactly those trees.
1149///
1150/// Affects `execve` only: a shared object mapped `PROT_EXEC` by `dlopen(3)` needs `ReadFile`, not
1151/// `Execute`, so an interpreter still loads its C extensions out of an `executable: false` tree.
1152#[derive(Debug, Clone, PartialEq, Eq)]
1153pub(crate) struct LandlockGrant {
1154    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1155    pub(crate) path: PathBuf,
1156    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1157    pub(crate) list_dir: bool,
1158    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1159    pub(crate) executable: bool,
1160}
1161
1162impl LandlockGrant {
1163    /// Wraps each derived `DT_NEEDED`-closure file path as a non-listable grant. A regular file
1164    /// has no meaningful `ReadDir`, so `list_dir: false` costs nothing. `executable: true`: this
1165    /// closure is exactly the set of paths `shell.allow` asked to run — see [`LandlockGrant`].
1166    fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
1167        paths
1168            .into_iter()
1169            .map(|path| LandlockGrant {
1170                path,
1171                list_dir: false,
1172                executable: true,
1173            })
1174            .collect()
1175    }
1176}
1177
1178/// Turns each `capabilities.shell.interpreter_runtime` directory into a [`LandlockGrant`] carrying
1179/// exactly the `list_dir` its author wrote. Pure and syscall-free (the declared paths need not
1180/// exist here — `apply_landlock_scope` skips any that fail to open), so it is unit-testable on
1181/// every platform, including non-Linux targets.
1182///
1183/// There is deliberately no path here that could widen a grant to a whole install prefix: it emits
1184/// one grant per explicitly declared directory and copies the author's `list_dir` verbatim,
1185/// inferring enumerability from nothing. A recognised interpreter's own import path is granted
1186/// separately, by [`resolve_derived_interpreter_grants`]; this is the route for an interpreter the
1187/// runtime does not recognise.
1188pub(crate) fn resolve_interpreter_runtime_grants(
1189    grants: &[InterpreterRuntimeGrant],
1190) -> Vec<LandlockGrant> {
1191    grants
1192        .iter()
1193        .flat_map(|grant| {
1194            grant.dirs.iter().map(|dir| LandlockGrant {
1195                path: PathBuf::from(&dir.path),
1196                list_dir: dir.list_dir,
1197                // Executable: an `interpreter_runtime` directory is named by an author precisely so
1198                // the interpreter it belongs to can run out of it, helper binaries included.
1199                executable: true,
1200            })
1201        })
1202        .collect()
1203}
1204
1205/// One [`LandlockGrant`] per entry staging derived from a recognised interpreter (see
1206/// [`crate::interpreter_runtime`]): each directory on its import path readable and listable, and
1207/// each zip archive on it and each extension-module library readable only.
1208///
1209/// `executable: false` on every one (see [`LandlockGrant`]): the manifest asked to run the
1210/// interpreter, which its own ELF closure grants, not the files it imports. A C extension module
1211/// still loads, because `dlopen(3)` needs `ReadFile` alone. The derivation never names `/` or an
1212/// ancestor of the interpreter's directory, so no grant here widens to an install prefix.
1213pub(crate) fn resolve_derived_interpreter_grants(
1214    derived: &[crate::interpreter_runtime::DerivedInterpreterRuntime],
1215) -> Vec<LandlockGrant> {
1216    let read_only = |path: &Path, list_dir: bool| LandlockGrant {
1217        path: path.to_path_buf(),
1218        list_dir,
1219        executable: false,
1220    };
1221    derived
1222        .iter()
1223        .flat_map(|runtime| {
1224            runtime
1225                .entries()
1226                .map(|(path, is_dir)| read_only(path, is_dir))
1227                .chain(runtime.libraries.iter().map(|path| read_only(path, false)))
1228                .collect::<Vec<_>>()
1229        })
1230        .collect()
1231}
1232
1233/// One [`LandlockGrant`] per host trust-store path staging found (see [`crate::trust_store`]): a
1234/// directory readable and listable, a CA bundle file readable.
1235///
1236/// `executable: false` on every one (see [`LandlockGrant`]): a certificate is read, never run. No
1237/// write right, as on every [`LandlockGrant`]. The network gate (a non-empty `shell.allow` and a
1238/// non-empty `network.allow`) lives in [`crate::trust_store::derive_trust_store`], so a policy
1239/// that fails it reaches here with nothing to grant; the derivation never names `/` or an
1240/// ancestor of a trust-store path, so no grant here reaches `/etc/ssl/private`.
1241///
1242/// Issued on every tier and consulted only on `KernelFull` and `KernelSealed`. On `KernelSealed`
1243/// a path the composed root does not bind stays unreachable: this grant adds no bind.
1244pub(crate) fn resolve_trust_store_grants(
1245    paths: &[crate::trust_store::TrustStorePath],
1246) -> Vec<LandlockGrant> {
1247    paths
1248        .iter()
1249        .map(|entry| LandlockGrant {
1250            path: entry.path.clone(),
1251            list_dir: entry.directory,
1252            executable: false,
1253        })
1254        .collect()
1255}
1256
1257/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
1258/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
1259/// allowlisted binary, each declared `interpreter_runtime` directory, and each directory a
1260/// recognised interpreter reported on its import path with the parent directory of each library
1261/// its extension modules need.
1262///
1263/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
1264/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
1265/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
1266/// derivation `scoped` needs has nothing left to do here.
1267///
1268/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
1269/// makes the composed root narrower rather than refusing the launch, which is why
1270/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
1271/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
1272/// required bind. This function is additive to that, not replaced by it.
1273///
1274/// Pure and syscall-free, so it is unit-testable on any platform.
1275pub(crate) fn resolve_sealed_bind_dirs(
1276    exec_allow_paths: &[PathBuf],
1277    policy: &CapabilityPolicy,
1278) -> Vec<PathBuf> {
1279    let mut dirs: Vec<PathBuf> = Vec::new();
1280    let candidates = exec_allow_paths
1281        .iter()
1282        .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1283        .chain(
1284            policy
1285                .shell_interpreter_runtime
1286                .iter()
1287                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1288        )
1289        .chain(
1290            policy
1291                .shell_derived_interpreter_runtime
1292                .iter()
1293                .flat_map(|runtime| {
1294                    runtime
1295                        .entries()
1296                        .filter(|(_, is_dir)| *is_dir)
1297                        .map(|(path, _)| path.to_path_buf())
1298                        .chain(
1299                            runtime
1300                                .libraries
1301                                .iter()
1302                                .filter_map(|library| library.parent().map(Path::to_path_buf)),
1303                        )
1304                        .collect::<Vec<_>>()
1305                }),
1306        );
1307
1308    for candidate in candidates {
1309        push_sealed_bind_dir(&mut dirs, candidate);
1310    }
1311    dirs
1312}
1313
1314/// Every directory this session may run something out of, for the host-path check
1315/// ([`crate::host_paths::check_execute_roots`]): the directory of each file its Landlock grants
1316/// mark executable (each resolved `shell.allow` binary, its ELF interpreter and `DT_NEEDED`
1317/// closure) and each executable directory grant (`interpreter_runtime` directories,
1318/// `staged_runtime` trees). Called by `stage_session` and `mur run --explain-scope`.
1319pub(crate) fn host_path_execute_roots(
1320    policy: &CapabilityPolicy,
1321) -> Vec<crate::host_paths::ExecuteRoot> {
1322    let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
1323    execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
1324}
1325
1326/// [`host_path_execute_roots`] from an already-resolved executable closure.
1327fn execute_roots_from(
1328    closure: &[PathBuf],
1329    policy: &CapabilityPolicy,
1330) -> Vec<crate::host_paths::ExecuteRoot> {
1331    let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
1332    let mut push = |dir: PathBuf, because: PathBuf| {
1333        let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
1334        if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
1335            roots.push(crate::host_paths::ExecuteRoot { dir, because });
1336        }
1337    };
1338    for file in closure {
1339        if let Some(parent) = file.parent() {
1340            push(parent.to_path_buf(), file.clone());
1341        }
1342    }
1343    for grant in &policy.shell_interpreter_runtime {
1344        for dir in &grant.dirs {
1345            push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
1346        }
1347    }
1348    for dir in resolve_staged_runtime_dirs(policy) {
1349        push(dir.clone(), dir);
1350    }
1351    roots
1352}
1353
1354/// What `tier` does with a host path.
1355pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
1356    match tier {
1357        EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
1358        EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
1359        EnforcementTier::KernelSeccompOnly
1360        | EnforcementTier::EnvironmentOnly
1361        | EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
1362    }
1363}
1364
1365/// Adds `candidate` to `dirs` as an optional composed-root bind unless it is relative, already
1366/// inside a fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, or already listed.
1367fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1368    if !candidate.is_absolute() {
1369        return;
1370    }
1371    if crate::sealed::SEALED_RUNTIME_PATHS
1372        .iter()
1373        .any(|root| candidate.starts_with(root))
1374    {
1375        return;
1376    }
1377    if dirs.contains(&candidate) {
1378        return;
1379    }
1380    dirs.push(candidate);
1381}
1382
1383/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
1384/// root's *required* read-only binds.
1385///
1386/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
1387/// way that function filters. Both differences follow from the same fact — these fail closed:
1388///
1389///   * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
1390///     Dropping it would be sound for an optional bind (the fixed list already covers it) but it
1391///     would quietly convert a required grant into a dependency on an unrelated list staying the
1392///     way it is today. `plan_composed_root` runs the required loop first and deduplicates by
1393///     target, so naming an already-covered path costs one registration and stays required.
1394///   * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
1395///     grant naming a tree this host does not have refuses the launch — see
1396///     `sealed::PlanBuilder::require_bind`.
1397///
1398/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
1399/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
1400/// layer that rejects one.
1401///
1402/// Pure and syscall-free, so it is unit-testable on any platform.
1403pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1404    let mut dirs: Vec<PathBuf> = Vec::new();
1405    for grant in &policy.shell_staged_runtime {
1406        let path = PathBuf::from(&grant.source_path);
1407        if !path.is_absolute() || dirs.contains(&path) {
1408            continue;
1409        }
1410        dirs.push(path);
1411    }
1412    dirs
1413}
1414
1415/// One listable [`LandlockGrant`] per staged runtime directory.
1416///
1417/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
1418/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
1419/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
1420/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
1421/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
1422/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
1423/// working at all.
1424///
1425/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
1426/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
1427/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
1428/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
1429/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
1430/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
1431/// same inode reached through the bind inside the root.
1432///
1433/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
1434/// of two independent reasons the tree cannot be mutated from inside a session.
1435///
1436/// Pure and syscall-free, so it is unit-testable on any platform.
1437pub(crate) fn resolve_staged_runtime_landlock_grants(
1438    policy: &CapabilityPolicy,
1439) -> Vec<LandlockGrant> {
1440    resolve_staged_runtime_dirs(policy)
1441        .into_iter()
1442        .map(|path| LandlockGrant {
1443            path,
1444            list_dir: true,
1445            executable: true,
1446        })
1447        .collect()
1448}
1449
1450/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1451/// `KernelSealed`.
1452///
1453/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1454/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1455/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1456/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1457/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1458/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1459/// enumerable.
1460///
1461/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1462/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1463///
1464/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1465/// the manifest never named, so it must enumerate them without becoming permission to run them.
1466///
1467/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1468/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1469/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1470/// nothing. Empty on every other tier.
1471///
1472/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1473/// to open), so unit-testable on every platform.
1474pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1475    if tier != EnforcementTier::KernelSealed {
1476        return Vec::new();
1477    }
1478    crate::sealed::SEALED_RUNTIME_PATHS
1479        .iter()
1480        .map(|path| LandlockGrant {
1481            path: PathBuf::from(path),
1482            list_dir: true,
1483            executable: false,
1484        })
1485        .collect()
1486}
1487
1488/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1489/// `KernelSealed`.
1490///
1491/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1492/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1493/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1494/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1495/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1496/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1497/// fail certificate verification over an otherwise-working TCP connection.
1498///
1499/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1500/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1501/// directories, regular files and symlinks.
1502/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1503/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1504/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1505///
1506/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1507/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1508/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1509/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1510/// prevent.
1511///
1512/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1513/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1514///
1515/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1516/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1517/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1518/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1519/// reading it reveals nothing sensitive. Empty on every other tier.
1520/// The trust store alone is granted on every Landlock tier, under the network gate, by
1521/// [`resolve_trust_store_grants`].
1522///
1523/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1524/// open), so unit-testable on every platform.
1525pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1526    if tier != EnforcementTier::KernelSealed {
1527        return Vec::new();
1528    }
1529    crate::sealed::SEALED_ETC_PATHS
1530        .iter()
1531        .map(|entry| LandlockGrant {
1532            path: PathBuf::from(entry.path),
1533            list_dir: entry.directory,
1534            executable: false,
1535        })
1536        .collect()
1537}
1538
1539/// The host files a `scoped` command reads to resolve `localhost` and the other names
1540/// `/etc/hosts` carries: the resolver's configuration and the hosts table. Each is also in
1541/// [`crate::sealed::SEALED_ETC_PATHS`], which grants them on `sealed`.
1542pub(crate) const SCOPED_NAME_RESOLUTION_PATHS: &[&str] = &["/etc/hosts", "/etc/nsswitch.conf"];
1543
1544/// One `ReadFile`-only grant per [`SCOPED_NAME_RESOLUTION_PATHS`] entry on `KernelFull`, and
1545/// nothing on any other tier: `KernelSealed` grants them through
1546/// [`resolve_sealed_etc_landlock_grants`], and the tiers without Landlock grant nothing.
1547///
1548/// Not gated on `network.allow`: `localhost` resolves without a network, and a server a command
1549/// starts on `localhost` is reached by name. Neither file names a secret — `/etc/resolv.conf`,
1550/// `/etc/passwd` and the rest of `/etc` stay denied on `scoped` — and neither is ever writable.
1551///
1552/// Pure and syscall-free, like the resolvers above.
1553pub(crate) fn resolve_scoped_name_resolution_landlock_grants(
1554    tier: EnforcementTier,
1555) -> Vec<LandlockGrant> {
1556    if tier != EnforcementTier::KernelFull {
1557        return Vec::new();
1558    }
1559    SCOPED_NAME_RESOLUTION_PATHS
1560        .iter()
1561        .map(|path| LandlockGrant {
1562            path: PathBuf::from(path),
1563            list_dir: false,
1564            executable: false,
1565        })
1566        .collect()
1567}
1568
1569// ---- fixed capsule device set ------------------------------------------------------------
1570//
1571// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1572// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1573// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1574// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1575// device, and none that takes one away.
1576//
1577// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1578// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1579// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1580// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1581
1582/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1583///
1584/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1585/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1586/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1587/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1588/// `list_dir` into a third meaning.
1589///
1590/// `writable` is the whole reason this type exists as more than a path list:
1591///
1592///   - `false` → `ReadFile` only. The device is readable and nothing more.
1593///   - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, which with the
1594///     session's temporary folder is one of the two writable paths outside the workdir.
1595///
1596/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1597/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1598/// module's Linux-only surface carries.
1599#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1601pub(crate) struct CapsuleDeviceGrant {
1602    pub(crate) path: &'static str,
1603    pub(crate) writable: bool,
1604}
1605
1606/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1607/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1608/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1609/// stays denied by the same mechanism that already denies every other unlisted path: with
1610/// `handle_access` declaring the full right-set of the ABI the ruleset is built at, "no rule" means
1611/// "denied", not "ungoverned".
1612///
1613///   - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1614///     outside the workdir is writable". A read-only grant would be broken, not merely strict:
1615///     bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1616///     shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1617///     and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1618///     write on a character device whose write side is defined to discard everything gives up no
1619///     confidentiality and no integrity — there is no state behind it to reach.
1620///     (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1621///     file is actually created, and `/dev/null` already exists.)
1622///   - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1623///     in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1624///     write there, so it does not get `WriteFile`.
1625///   - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1626///     filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1627///     device outright and fall over if they cannot.
1628///
1629/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1630/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1631/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1632/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1633/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1634/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1635///
1636/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1637/// workload has been observed failing on the missing device, and record that failure in the same
1638/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1639///
1640/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1641/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1642/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1643/// implementation should *retire* this constant, not merge with it.
1644#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1645pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1646    CapsuleDeviceGrant {
1647        path: "/dev/null",
1648        writable: true,
1649    },
1650    CapsuleDeviceGrant {
1651        path: "/dev/zero",
1652        writable: false,
1653    },
1654    CapsuleDeviceGrant {
1655        path: "/dev/urandom",
1656        writable: false,
1657    },
1658];
1659
1660/// Network decision for one destination IP.
1661/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1662/// no reason to open any subprocess socket).
1663#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1664pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1665    network_allow_ips.contains(&ip)
1666}
1667
1668// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1669// rather than `libc::AF_*`: the filter these feed is
1670// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1671// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1672// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1673// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1674// of the three from `libc` and two from literals would be worse than taking all three the same
1675// way. Values are stable kernel ABI: `include/linux/socket.h`.
1676const LINUX_AF_UNIX: i32 = 1;
1677const LINUX_AF_INET: i32 = 2;
1678const LINUX_AF_INET6: i32 = 10;
1679const LINUX_AF_NETLINK: i32 = 16;
1680const LINUX_AF_PACKET: i32 = 17;
1681
1682/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1683/// `capabilities.network.unix_sockets` declaration.
1684///
1685/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1686/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1687/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1688///
1689/// Why a domain check is a classic BPF rule and not a notify:
1690/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1691/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1692/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1693/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1694/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1695/// pointed-to argument out of another task's memory invites.
1696///
1697/// The three families, and why they are not symmetric:
1698///   - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1699///     by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1700///     need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1701///     Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1702///   - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1703///     capture and injection) are denied unconditionally, with no manifest key to widen them.
1704///     Neither has a shell-tool use case comparable to a local daemon socket, and both are
1705///     strictly more dangerous than one. Widening them would be a deliberate future decision with
1706///     its own justification, not an oversight to be fixed by adding a flag here.
1707///
1708/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1709/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1710/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1711/// get a positive rule — since the filter's default action is a deny, a family named by neither
1712/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1713#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1714pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1715    let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1716    if !unix_sockets_allowed {
1717        denied.push(LINUX_AF_UNIX);
1718    }
1719    denied
1720}
1721
1722/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1723/// `capabilities.network.unix_sockets` declaration. The exact complement of
1724/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1725///
1726/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1727/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1728/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1729/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1730/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1731/// equality-matched sets that a test can check against each other.
1732///
1733/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1734/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1735/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1736/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1737/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1738/// reason) if a real workload needs it.
1739#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1740pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1741    let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1742    if unix_sockets_allowed {
1743        allowed.push(LINUX_AF_UNIX);
1744    }
1745    allowed
1746}
1747
1748/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1749/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1750///
1751/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1752/// reason: the filter is always compiled for a Linux child.
1753#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1754const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1755#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1756const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1757
1758/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1759/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1760/// is the maintained source of the same list Docker ships).
1761///
1762/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1763/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1764///
1765///   - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1766///     [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1767///
1768/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1769/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1770/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1771/// child's very first act after this filter loads is the `execve` that turns it into the tool
1772/// binary, so a denial here refuses every capsule outright.
1773///
1774/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1775/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1776/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1777/// `io_uring` is the one that
1778/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1779/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1780/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1781///
1782/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1783/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1784/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1785/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1786/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1787/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1788#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1789pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1790    // ---- process / thread lifecycle ----
1791    // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1792    // doc comment for the mechanism that replaced their former `Notify` rules.
1793    "execve",
1794    "execveat",
1795    // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1796    // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1797    // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1798    // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1799    // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1800    // largely inert.
1801    "clone",
1802    "clone3",
1803    "fork",
1804    "vfork",
1805    "exit",
1806    "exit_group",
1807    "wait4",
1808    "waitid",
1809    "kill",
1810    "tkill",
1811    "tgkill",
1812    "rt_sigaction",
1813    "rt_sigprocmask",
1814    "rt_sigreturn",
1815    "rt_sigsuspend",
1816    "rt_sigpending",
1817    "rt_sigtimedwait",
1818    "rt_sigqueueinfo",
1819    "rt_tgsigqueueinfo",
1820    "sigaltstack",
1821    // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1822    // would break every `sleep`/`read` that takes a signal.
1823    "restart_syscall",
1824    "pause",
1825    "set_tid_address",
1826    "set_robust_list",
1827    "get_robust_list",
1828    // glibc 2.35+ registers a restartable sequence on every thread start.
1829    "rseq",
1830    "prctl",
1831    "arch_prctl",
1832    "getpid",
1833    "getppid",
1834    "gettid",
1835    "getpgrp",
1836    "getpgid",
1837    "setpgid",
1838    "getsid",
1839    "setsid",
1840    "getpriority",
1841    "setpriority",
1842    "ioprio_get",
1843    "ioprio_set",
1844    "sched_yield",
1845    "sched_getaffinity",
1846    "sched_setaffinity",
1847    "sched_getparam",
1848    "sched_setparam",
1849    "sched_getscheduler",
1850    "sched_setscheduler",
1851    "sched_getattr",
1852    "sched_setattr",
1853    "sched_get_priority_max",
1854    "sched_get_priority_min",
1855    "sched_rr_get_interval",
1856    "getcpu",
1857    "capget",
1858    "capset",
1859    "prlimit64",
1860    "getrlimit",
1861    "setrlimit",
1862    "getrusage",
1863    "getitimer",
1864    "setitimer",
1865    "times",
1866    "uname",
1867    "sysinfo",
1868    // pidfd process handles: they only ever address a process the caller could already signal.
1869    "pidfd_open",
1870    "pidfd_send_signal",
1871    // ---- memory ----
1872    "mmap",
1873    "munmap",
1874    "mprotect",
1875    "mremap",
1876    "madvise",
1877    "brk",
1878    "mlock",
1879    "mlock2",
1880    "munlock",
1881    "mlockall",
1882    "munlockall",
1883    "membarrier",
1884    "msync",
1885    "mincore",
1886    "memfd_create",
1887    "pkey_alloc",
1888    "pkey_free",
1889    "pkey_mprotect",
1890    "shmget",
1891    "shmat",
1892    "shmdt",
1893    "shmctl",
1894    // ---- file I/O ----
1895    "open",
1896    "openat",
1897    "openat2",
1898    "creat",
1899    "close",
1900    "close_range",
1901    "read",
1902    "write",
1903    "pread64",
1904    "pwrite64",
1905    "readv",
1906    "writev",
1907    "preadv",
1908    "pwritev",
1909    "preadv2",
1910    "pwritev2",
1911    "lseek",
1912    "dup",
1913    "dup2",
1914    "dup3",
1915    "fcntl",
1916    "flock",
1917    "fsync",
1918    "fdatasync",
1919    "sync",
1920    "syncfs",
1921    "sync_file_range",
1922    "fadvise64",
1923    "readahead",
1924    "truncate",
1925    "ftruncate",
1926    "fallocate",
1927    "stat",
1928    "fstat",
1929    "lstat",
1930    "newfstatat",
1931    "statx",
1932    "access",
1933    "faccessat",
1934    "faccessat2",
1935    "getdents",
1936    "getdents64",
1937    "getcwd",
1938    "chdir",
1939    "fchdir",
1940    "mkdir",
1941    "mkdirat",
1942    "rmdir",
1943    "unlink",
1944    "unlinkat",
1945    "rename",
1946    "renameat",
1947    "renameat2",
1948    "link",
1949    "linkat",
1950    "symlink",
1951    "symlinkat",
1952    "readlink",
1953    "readlinkat",
1954    "chmod",
1955    "fchmod",
1956    "fchmodat",
1957    // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
1958    // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
1959    "fchmodat2",
1960    "chown",
1961    "fchown",
1962    "fchownat",
1963    "lchown",
1964    "umask",
1965    "utime",
1966    "utimes",
1967    "utimensat",
1968    "futimesat",
1969    // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
1970    // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
1971    // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
1972    "mknod",
1973    "mknodat",
1974    "sendfile",
1975    "copy_file_range",
1976    "splice",
1977    "tee",
1978    "vmsplice",
1979    "ioctl",
1980    "poll",
1981    "ppoll",
1982    "select",
1983    "pselect6",
1984    "epoll_create",
1985    "epoll_create1",
1986    "epoll_ctl",
1987    "epoll_wait",
1988    "epoll_pwait",
1989    "epoll_pwait2",
1990    "eventfd",
1991    "eventfd2",
1992    "pipe",
1993    "pipe2",
1994    "inotify_init",
1995    "inotify_init1",
1996    "inotify_add_watch",
1997    "inotify_rm_watch",
1998    "signalfd",
1999    "signalfd4",
2000    "timerfd_create",
2001    "timerfd_settime",
2002    "timerfd_gettime",
2003    "getxattr",
2004    "lgetxattr",
2005    "fgetxattr",
2006    "setxattr",
2007    "lsetxattr",
2008    "fsetxattr",
2009    "listxattr",
2010    "llistxattr",
2011    "flistxattr",
2012    "removexattr",
2013    "lremovexattr",
2014    "fremovexattr",
2015    "statfs",
2016    "fstatfs",
2017    // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
2018    // into an open file, ignoring the path it was reached by) stays denied.
2019    "name_to_handle_at",
2020    // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
2021    // path resolution of its own, so it carries none of the LSM-bypass property that got the
2022    // `io_uring_*` family denied.
2023    "io_setup",
2024    "io_destroy",
2025    "io_submit",
2026    "io_cancel",
2027    "io_getevents",
2028    // ---- sockets ----
2029    // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
2030    //
2031    // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
2032    // capsule's own network namespace has no route to anything except the egress proxy. The
2033    // destination check lives there, not in this filter — see `crate::network_namespace`.
2034    "connect",
2035    "sendto",
2036    "socketpair",
2037    "bind",
2038    "listen",
2039    "accept",
2040    "accept4",
2041    "getsockname",
2042    "getpeername",
2043    "getsockopt",
2044    "setsockopt",
2045    "shutdown",
2046    "sendmsg",
2047    "sendmmsg",
2048    "recvfrom",
2049    "recvmsg",
2050    "recvmmsg",
2051    // ---- time ----
2052    "clock_gettime",
2053    "clock_getres",
2054    "clock_nanosleep",
2055    "nanosleep",
2056    "gettimeofday",
2057    "time",
2058    "timer_create",
2059    "timer_settime",
2060    "timer_gettime",
2061    "timer_getoverrun",
2062    "timer_delete",
2063    "alarm",
2064    // ---- System V / POSIX IPC ----
2065    "msgget",
2066    "msgsnd",
2067    "msgrcv",
2068    "msgctl",
2069    "semget",
2070    "semop",
2071    "semctl",
2072    "semtimedop",
2073    "mq_open",
2074    "mq_unlink",
2075    "mq_timedsend",
2076    "mq_timedreceive",
2077    "mq_notify",
2078    "mq_getsetattr",
2079    // ---- misc ----
2080    "getrandom",
2081    "futex",
2082    "futex_waitv",
2083    "getuid",
2084    "geteuid",
2085    "getgid",
2086    "getegid",
2087    "setuid",
2088    "setgid",
2089    "setreuid",
2090    "setregid",
2091    "setresuid",
2092    "setresgid",
2093    "getresuid",
2094    "getresgid",
2095    "getgroups",
2096    "setgroups",
2097    "setfsuid",
2098    "setfsgid",
2099];
2100
2101/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
2102/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
2103/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
2104/// allowlist while reconciling it against a newer upstream profile.
2105///
2106/// Three groups, and they are not equally negotiable:
2107///
2108///   1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
2109///      them: `io_uring_setup`/`io_uring_enter`/
2110///      `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
2111///      around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
2112///      `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
2113///      (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
2114///      `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
2115///      `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
2116///      deliberately stricter there.
2117///   2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
2118///      mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
2119///      clock setting, NUMA policy, `fanotify`.
2120///   3. **Denied instead of argument-conditioned.** Docker allows these only for specific
2121///      argument values, which a per-syscall-name allowlist cannot express and which would
2122///      otherwise have to be routed through a userspace decision this filter no longer makes.
2123///      Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
2124///      standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
2125///      itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
2126// The list is a specification, not an input to the filter build: its one consumer is the
2127// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
2128// the thing that test checks against.
2129#[allow(dead_code)]
2130pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2131    // 1. demonstrated reachable on bare metal
2132    "io_uring_setup",
2133    "io_uring_enter",
2134    "io_uring_register",
2135    "bpf",
2136    "userfaultfd",
2137    "perf_event_open",
2138    "process_vm_readv",
2139    "process_vm_writev",
2140    "open_by_handle_at",
2141    "keyctl",
2142    "add_key",
2143    "request_key",
2144    "ptrace",
2145    // 2. privileged / host-state
2146    "mount",
2147    "umount2",
2148    "pivot_root",
2149    "chroot",
2150    "unshare",
2151    "setns",
2152    "init_module",
2153    "finit_module",
2154    "delete_module",
2155    "kexec_load",
2156    "kexec_file_load",
2157    "reboot",
2158    "swapon",
2159    "swapoff",
2160    "acct",
2161    "quotactl",
2162    "nfsservctl",
2163    "iopl",
2164    "ioperm",
2165    "vm86",
2166    "vm86old",
2167    "_sysctl",
2168    "sysfs",
2169    "uselib",
2170    "create_module",
2171    "get_kernel_syms",
2172    "query_module",
2173    "lookup_dcookie",
2174    "clock_adjtime",
2175    "clock_settime",
2176    "settimeofday",
2177    "stime",
2178    "adjtimex",
2179    "set_mempolicy",
2180    "get_mempolicy",
2181    "mbind",
2182    "migrate_pages",
2183    "move_pages",
2184    "fanotify_init",
2185    "fanotify_mark",
2186    // 3. denied instead of argument-conditioned
2187    "seccomp",
2188    "personality",
2189    "kcmp",
2190];
2191
2192/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
2193/// project treats the filter as having outgrown its headroom. Held against the *measured* export
2194/// of the real filter by a test, not against an estimate: growing
2195/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
2196/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
2197///
2198/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
2199/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
2200/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
2201/// number was chosen against.
2202#[allow(dead_code)]
2203pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2204
2205/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
2206/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
2207/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
2208/// two filters that straddle it in forked children.
2209#[allow(dead_code)]
2210pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2211
2212/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
2213/// attached to one task may total, counting every filter it already carries plus a small
2214/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
2215/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
2216/// quoted: a test stacks filters in a forked child until the refusal arrives.
2217#[allow(dead_code)]
2218pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2219
2220/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
2221#[derive(Debug, Clone)]
2222pub(crate) struct ShellEnforcement {
2223    pub(crate) tier: EnforcementTier,
2224    /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
2225    /// by [`resolve_network_allowlist_ips`].
2226    ///
2227    /// The narrower of the egress proxy's two checks — the one applied to a destination the
2228    /// capsule reached without ever resolving a name for it (a hardcoded literal). See
2229    /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
2230    /// [`network_ip_allowed`].
2231    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2232    pub(crate) network_allow_ips: Vec<IpAddr>,
2233    /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
2234    /// checks a name when it resolves it and again when the connection to the address that name
2235    /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
2236    /// many tenants) in either direction.
2237    ///
2238    /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
2239    /// entry cannot mean one thing to a WASM guest and another to a subprocess.
2240    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2241    pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2242    /// The TCP ports the capsule's network namespace opens listeners on, derived from the
2243    /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
2244    /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
2245    /// consulted — the strongest form the refusal can take.
2246    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2247    pub(crate) egress_tcp_ports: Vec<u16>,
2248    /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
2249    /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
2250    /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
2251    /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
2252    /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
2253    /// platform for parity but never read off Linux.
2254    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2255    pub(crate) unix_sockets_allowed: bool,
2256    /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
2257    /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
2258    /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
2259    /// writes into its workdir can run, under any name. Consumed only by
2260    /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
2261    /// for parity but never read off Linux.
2262    ///
2263    /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
2264    /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
2265    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2266    pub(crate) workdir_exec: bool,
2267    /// The Landlock ABI the child's ruleset is built at: [`ruleset_landlock_abi`] of the session's
2268    /// [`HostProbe`] and [`Self::tier`], decided here so the ABI `session_start` records is the one
2269    /// installed. `None` on the tiers with no Landlock domain, and off Linux always.
2270    ///
2271    /// On `KernelFull`/`KernelSealed` a `None` builds at ABI v1, the narrowest set, so a missing
2272    /// reading can only narrow what the capsule may do. `Some(1)` likewise reproduces exactly
2273    /// what a 5.13–5.18 kernel installs, which is how the tests force that ruleset on a newer one.
2274    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2275    pub(crate) landlock_abi: Option<u8>,
2276    /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
2277    /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
2278    /// stdlib. Seven origins, combined here:
2279    ///
2280    ///   - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
2281    ///     shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
2282    ///     (they are individual files, where `ReadDir` was always a no-op);
2283    ///   - one grant per `capabilities.shell.interpreter_runtime` directory, from
2284    ///     `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
2285    ///     declared;
2286    ///   - one non-executable grant per entry a recognised interpreter reported on its import
2287    ///     path at staging, and per library its extension modules need, from
2288    ///     `resolve_derived_interpreter_grants` — listable for a directory;
2289    ///   - one non-executable read grant per host trust-store path staging found for a capsule
2290    ///     with a non-empty `shell.allow` and `network.allow`, from `resolve_trust_store_grants` —
2291    ///     listable for a directory;
2292    ///   - one listable grant per `capabilities.shell.staged_runtime` tree, from
2293    ///     `resolve_staged_runtime_landlock_grants`;
2294    ///   - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
2295    ///     [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
2296    ///     `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
2297    ///     tree, which nothing else grants;
2298    ///   - on `KernelSealed` **only**, one non-executable read grant per fixed
2299    ///     [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
2300    ///     (`list_dir` per entry, since that list mixes directories and files) — the composed
2301    ///     root's `/etc` allowlist, likewise granted by nothing else.
2302    ///
2303    /// The closure, `interpreter_runtime` and `staged_runtime` grants carry `executable: true`;
2304    /// the derived interpreter grants, the trust-store grants and the two tier-issued ones do not,
2305    /// and that asymmetry is load-bearing — see [`LandlockGrant`].
2306    ///
2307    /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
2308    /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
2309    /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
2310    /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
2311    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2312    pub(crate) landlock_grants: Vec<LandlockGrant>,
2313    /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
2314    /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
2315    /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
2316    ///
2317    /// Whole directories, never files: a bind carries a directory's whole contents, so this must
2318    /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
2319    /// every platform for parity, exactly like `landlock_grants` above.
2320    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2321    pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2322    /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
2323    /// read-only into the composed root at its own absolute path.
2324    ///
2325    /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
2326    /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
2327    /// while a staged tree that cannot be mounted aborts the composed-root construction before
2328    /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
2329    /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
2330    /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
2331    /// parity, exactly like the two fields above.
2332    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2333    pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2334    /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
2335    /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
2336    /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
2337    /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
2338    /// half. See [`crate::resources`].
2339    pub(crate) resource_limits: crate::resources::HostResourceLimits,
2340    /// The manifest's own `capabilities.resources` block, every field as declared. The
2341    /// [`EnforcementTier::Unconfined`] tier sets an rlimit only for a field declared here, where
2342    /// every other tier sets [`Self::resource_limits`].
2343    pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
2344    /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
2345    /// for capsules that can actually spawn a subprocess). `None` on macOS always, and on
2346    /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
2347    /// spawn one and could not be given a scope never reaches here, because the launch is
2348    /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
2349    ///
2350    /// Its presence decides each spawn's `RLIMIT_NPROC` ([`Self::nproc_bound`]): a spawn that
2351    /// joins it is bounded by `pids.max` alone.
2352    pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2353    /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
2354    /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
2355    /// only at the next agent turn.
2356    pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2357    /// The session's one network namespace, which every command on a kernel tier joins.
2358    ///
2359    /// Shared by every clone of this enforcement, so a listener one command starts is reachable
2360    /// from the next. A fresh, not-yet-created namespace from [`Self::resolve`] and
2361    /// [`Self::environment_only`]; a plan inside a session takes its session's through
2362    /// [`Self::with_session_netns`]. Released by [`Self::end_session`].
2363    pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2364    /// The session's runtime directory and its `sealed-etc/` sibling: `$MURMUR_RUNTIME_DIR` in
2365    /// every command's environment, a read-only Landlock rule on the Landlock tiers, a read-only
2366    /// bind on `KernelSealed`, and the source of the composed root's `/etc/passwd` and
2367    /// `/etc/group`. `None` outside a session, where a command is told of no runtime directory
2368    /// and a sealed root cannot be composed.
2369    pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2370    /// The session's runtime directory itself, removed by [`Self::remove_runtime_dir`] on the
2371    /// process exits that run no destructor. Set only on the session's own enforcement.
2372    pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2373    /// How a literal `/tmp` reaches the session's temporary folder (`runtime_dir`'s `temp`):
2374    /// the session's [`crate::temp_dir::temp_mapping`] answer, through [`Self::with_temp_mapping`],
2375    /// or [`crate::temp_dir::tier_temp_mapping`] of [`Self::tier`] until one is handed in. Read
2376    /// only while the enforcement holds a folder.
2377    pub(crate) temp_mapping: crate::temp_dir::TempMapping,
2378    /// Which `/proc` this enforcement's commands see: [`crate::proc_view::proc_view`] of the
2379    /// probe [`Self::resolve`] was handed, or one handed in through [`Self::with_proc_view`].
2380    /// Read through [`Self::installed_proc_view`], which keys it on [`Self::tier`].
2381    pub(crate) proc_view: crate::proc_view::ProcView,
2382    /// The host folders `~/.murmur/config.yaml` lends this session ([`CapabilityPolicy::host_paths`]),
2383    /// in config order. On `KernelFull` and `KernelSealed` each gets a Landlock rule, `ReadFile |
2384    /// ReadDir` for `read` and the workdir's rights without `Execute` for `read-write`, taken on a
2385    /// descriptor opened at every spawn, fail-closed: a host path that can no longer be opened
2386    /// fails the spawn naming it. On `KernelSealed` each is also a required, verified bind at its
2387    /// own absolute path. Nothing is added on the tiers with no filesystem scope.
2388    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2389    pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
2390    /// On `KernelSealed` only: every execute root (see [`host_path_execute_roots`]) inside a
2391    /// `read-write` host path, bound read-only and required on top of it so the capsule cannot
2392    /// rewrite what it may run. Empty on every other tier, where such an entry is refused at
2393    /// staging instead.
2394    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2395    pub(crate) host_path_read_only_binds: Vec<PathBuf>,
2396}
2397
2398impl ShellEnforcement {
2399    /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
2400    ///
2401    /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
2402    /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
2403    /// caller creates and attaches with [`Self::with_host_bounding`].
2404    ///
2405    /// `decision` is the session's [`crate::containment::ContainmentDecision`], and its applied
2406    /// tier is the one installed: whether a sealed-capable host builds a composed root is decided
2407    /// there, from the declared floor, so the tier installed here is the one the session's trace
2408    /// names.
2409    ///
2410    /// `probe` is the reading `decision` was made from — for a session, the [`HostProbe`]
2411    /// `runtime::stage_session` took and carried on the `StagedSession` — and supplies the
2412    /// kernel's Landlock ABI.
2413    pub(crate) fn resolve(
2414        policy: &CapabilityPolicy,
2415        decision: &crate::containment::ContainmentDecision,
2416        probe: HostProbe,
2417    ) -> Result<Self, String> {
2418        let tier = decision.applied_tier();
2419        let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2420        let network_allow_rules =
2421            crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2422                .map_err(|error| error.to_string())?;
2423        let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2424        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
2425        // The `DT_NEEDED`-closure files (individual files → non-listable), one grant per
2426        // author-declared `interpreter_runtime` directory (each with its own `list_dir`), and one
2427        // read-only grant per entry a recognised interpreter reported on its own import path at
2428        // staging. A directory neither the manifest names nor an allowlisted interpreter reports
2429        // never receives a rule, regardless of what it holds.
2430        let closure = resolve_landlock_grants(&exec_allow_paths);
2431        // Re-checked here, not only at staging: a plan step or a session check resolves its own
2432        // enforcement, and none may install a read-write host path over what it runs on
2433        // `KernelFull`. On `KernelSealed` the execute roots inside one come back to be bound
2434        // read-only on top of it.
2435        let host_path_read_only_binds = crate::host_paths::check_execute_roots(
2436            &policy.host_paths,
2437            &execute_roots_from(&closure, policy),
2438            host_path_mechanism(tier),
2439        )
2440        .map_err(|refusal| refusal.to_string())?;
2441        let mut landlock_grants = LandlockGrant::non_listable_files(closure);
2442        landlock_grants.extend(resolve_interpreter_runtime_grants(
2443            &policy.shell_interpreter_runtime,
2444        ));
2445        landlock_grants.extend(resolve_derived_interpreter_grants(
2446            &policy.shell_derived_interpreter_runtime,
2447        ));
2448        // Plus one read-only grant per host trust-store path staging found. Not tier-gated: the
2449        // network gate lives in the derivation, which leaves the field empty for a capsule whose
2450        // commands cannot reach the network.
2451        landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2452        // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
2453        // composed root and then denied by the Landlock ruleset installed inside it — see
2454        // `resolve_staged_runtime_landlock_grants`.
2455        landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2456        // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
2457        // directory — the composed root binds them but Landlock inside the root still denies
2458        // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
2459        // `scoped` capsule runs on) the same grant would expose real host directory shape. The
2460        // resolver applies that gate itself and returns nothing on every other tier.
2461        landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2462        // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
2463        // mounted-but-denied bug one directory over, and the reason TLS certificate verification
2464        // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
2465        // `KernelFull` these rules would apply to the *host's* /etc.
2466        landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2467        // And, on `KernelFull` only, `/etc/hosts` and `/etc/nsswitch.conf` read-only, so
2468        // `localhost` resolves; `sealed` has them from the line above.
2469        landlock_grants.extend(resolve_scoped_name_resolution_landlock_grants(tier));
2470        let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2471        let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2472        Ok(Self {
2473            tier,
2474            network_allow_ips,
2475            network_allow_rules,
2476            egress_tcp_ports,
2477            unix_sockets_allowed: policy.unix_sockets_allowed,
2478            workdir_exec: policy.workdir_exec_allowed,
2479            landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2480            landlock_grants,
2481            sealed_bind_dirs,
2482            staged_runtime_dirs,
2483            resource_limits: policy.resources,
2484            declared_resources: policy.declared_resources,
2485            cgroup_scope: None,
2486            workdir_guard: None,
2487            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2488            runtime_dir: None,
2489            runtime_dir_owner: None,
2490            temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2491            proc_view: crate::proc_view::proc_view(tier, &probe.namespaces),
2492            host_paths: policy.host_paths.clone(),
2493            host_path_read_only_binds,
2494        })
2495    }
2496
2497    /// [`Self::resolve`] for a capsule declaring `declared`, decided from `probe` with the
2498    /// runtime directory outside the workdir: the seam tests resolve an enforcement through.
2499    #[cfg(test)]
2500    pub(crate) fn resolve_declared(
2501        policy: &CapabilityPolicy,
2502        declared: murmur_artifact::ContainmentClass,
2503        probe: HostProbe,
2504    ) -> Result<Self, String> {
2505        let decision = crate::containment::ContainmentDecision::decide(
2506            probe.tier(),
2507            declared,
2508            policy.workdir_exec_allowed,
2509            false,
2510        );
2511        Self::resolve(policy, &decision, probe)
2512    }
2513
2514    /// Attach the session's runtime directory, and with `owner` the directory itself, so a
2515    /// process exit that runs no destructor removes it ([`Self::remove_runtime_dir`]).
2516    pub(crate) fn with_runtime_dir(
2517        mut self,
2518        roots: crate::runtime_dir::RuntimeDirRoots,
2519        owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2520    ) -> Self {
2521        self.runtime_dir = Some(roots);
2522        self.runtime_dir_owner = owner;
2523        self
2524    }
2525
2526    /// The runtime directory's absolute path, the value of `$MURMUR_RUNTIME_DIR`.
2527    pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2528        self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2529    }
2530
2531    /// The session's records directory, held. `None` for an enforcement held outside a session.
2532    pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2533        self.runtime_dir
2534            .as_ref()
2535            .and_then(|roots| roots.records.as_ref())
2536    }
2537
2538    /// The records directory's absolute path, the value of `$MURMUR_RECORDS_DIR`. `None` for an
2539    /// enforcement held outside a session.
2540    pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2541        self.records_root()
2542            .map(crate::workdir_fs::WorkdirRoot::path)
2543    }
2544
2545    /// The session's temporary folder's absolute path. `None` for an enforcement held outside a
2546    /// session.
2547    pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2548        self.runtime_dir
2549            .as_ref()
2550            .and_then(|roots| roots.temp.as_ref())
2551            .map(crate::workdir_fs::WorkdirRoot::path)
2552    }
2553
2554    /// The session's home folder, `home/` in its records directory, held. `None` for an
2555    /// enforcement held outside a session, which runs no command.
2556    pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2557        self.runtime_dir
2558            .as_ref()
2559            .and_then(|roots| roots.home.as_ref())
2560    }
2561
2562    /// The home folder's absolute path, the value of `$HOME` and the synthetic passwd's home
2563    /// directory on every tier. `None` for an enforcement held outside a session.
2564    pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2565        self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2566    }
2567
2568    /// Hand in the session's [`crate::temp_dir::temp_mapping`] answer, decided for this
2569    /// enforcement's tier.
2570    pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2571        self.temp_mapping = mapping;
2572        self
2573    }
2574
2575    /// What this enforcement installs for `/tmp` while it holds a folder: the composed root's bind
2576    /// on `KernelSealed`, a private mount namespace on `KernelFull` and `KernelSeccompOnly` when
2577    /// [`Self::temp_mapping`] says so, and nothing otherwise. Keyed on [`Self::tier`] as well, so
2578    /// no tier is ever told of a mechanism it does not install.
2579    pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2580        use crate::temp_dir::TempMapping;
2581        match (self.tier, self.temp_mapping) {
2582            (
2583                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2584                TempMapping::MountNamespace,
2585            ) => TempMapping::MountNamespace,
2586            (
2587                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2588                TempMapping::NotMapped(reason),
2589            ) => TempMapping::NotMapped(reason),
2590            (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2591        }
2592    }
2593
2594    /// Hand in the `/proc` view this enforcement's commands get, in place of the one
2595    /// [`Self::resolve`] decided from its probe.
2596    #[cfg(all(test, target_os = "linux"))]
2597    pub(crate) fn with_proc_view(mut self, view: crate::proc_view::ProcView) -> Self {
2598        self.proc_view = view;
2599        self
2600    }
2601
2602    /// What this enforcement installs for `/proc`: the private view on the kernel tiers when
2603    /// [`Self::proc_view`] says so, the host's otherwise. Keyed on [`Self::tier`], so no tier is
2604    /// told of a mechanism it does not install.
2605    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2606    pub(crate) fn installed_proc_view(&self) -> crate::proc_view::ProcView {
2607        match (self.tier, &self.proc_view) {
2608            (
2609                EnforcementTier::KernelFull
2610                | EnforcementTier::KernelSealed
2611                | EnforcementTier::KernelSeccompOnly,
2612                view,
2613            ) => view.clone(),
2614            (tier, _) => crate::proc_view::tier_proc_view(tier),
2615        }
2616    }
2617
2618    /// The value of `TMPDIR`, `TMP` and `TEMP` in every command this enforcement spawns: `/tmp`
2619    /// when a literal `/tmp` is the session's temporary folder, the folder's absolute path when
2620    /// not, and `None` when the enforcement holds no folder.
2621    pub(crate) fn tmpdir_value(&self) -> Option<String> {
2622        self.temp_dir_path()
2623            .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2624    }
2625
2626    /// Removes the session's runtime directory now, ahead of a process exit that runs no
2627    /// destructor. Does nothing on an enforcement that does not own one.
2628    pub(crate) fn remove_runtime_dir(&self) {
2629        if let Some(owner) = &self.runtime_dir_owner {
2630            owner.remove();
2631        }
2632    }
2633
2634    /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
2635    /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
2636    ///
2637    /// Separate from [`Self::resolve`] because both are *live* per-session resources with
2638    /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
2639    /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
2640    /// creation so the fail-closed launch refusal can happen with a typed error before this is
2641    /// ever reached.
2642    pub(crate) fn with_host_bounding(
2643        mut self,
2644        cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2645        workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2646    ) -> Self {
2647        self.cgroup_scope = cgroup_scope;
2648        self.workdir_guard = workdir_guard;
2649        self
2650    }
2651
2652    /// The `RLIMIT_NPROC` decision for one spawn, made in the parent before its `pre_exec`
2653    /// closure is installed.
2654    ///
2655    /// [`crate::resources::NprocBound::CgroupPidsMax`] when the spawn will join a scope that
2656    /// places it, because both closures that receive this run the join fatally before `execve`.
2657    /// Otherwise [`crate::resources::NprocBound::Headroom`], with a baseline measured by
2658    /// `crate::resources::uid_task_count` on this call — a `/proc` walk on Linux — so call it
2659    /// once per spawn and never from inside `pre_exec`.
2660    pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2661        if self
2662            .cgroup_scope
2663            .as_ref()
2664            .is_some_and(|scope| scope.places_subprocesses())
2665        {
2666            crate::resources::NprocBound::CgroupPidsMax
2667        } else {
2668            crate::resources::NprocBound::Headroom {
2669                baseline: crate::resources::uid_task_count().unwrap_or(0),
2670            }
2671        }
2672    }
2673
2674    /// The latched workdir-size breach, if the guard has seen one.
2675    pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2676        self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2677    }
2678
2679    /// Refuse to spawn once the workdir ceiling has been crossed.
2680    ///
2681    /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
2682    /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
2683    /// the periodic check notices the breach, and the very next subprocess never starts.
2684    pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2685        match self.workdir_breach() {
2686            Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2687            None => Ok(()),
2688        }
2689    }
2690
2691    /// The permanent macOS/non-Linux value, and also what tests should construct directly to
2692    /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
2693    /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
2694    /// test code calls this directly today — production always goes through `resolve()`,
2695    /// which independently arrives at the same tier value on non-Linux hosts.)
2696    #[cfg_attr(not(test), allow(dead_code))]
2697    pub(crate) fn environment_only() -> Self {
2698        Self {
2699            tier: EnforcementTier::EnvironmentOnly,
2700            network_allow_ips: Vec::new(),
2701            network_allow_rules: Vec::new(),
2702            egress_tcp_ports: Vec::new(),
2703            // Denied, matching every other grant this constructor zeroes out. Inert on this tier
2704            // (no seccomp filter is installed at all), but it must not read as "allowed".
2705            unix_sockets_allowed: false,
2706            // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
2707            // Landlock domain exists to withhold anything from), but it must not read as "allowed".
2708            workdir_exec: false,
2709            // No Landlock domain on this tier. A test that spreads this over a Landlock tier
2710            // builds at ABI v1, the narrowest set.
2711            landlock_abi: None,
2712            landlock_grants: Vec::new(),
2713            sealed_bind_dirs: Vec::new(),
2714            staged_runtime_dirs: Vec::new(),
2715            // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
2716            // zeroing them out here would misrepresent what a real macOS host does.
2717            resource_limits: crate::resources::HostResourceLimits::default(),
2718            declared_resources: murmur_artifact::ResourceCapabilities::default(),
2719            cgroup_scope: None,
2720            workdir_guard: None,
2721            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2722            runtime_dir: None,
2723            runtime_dir_owner: None,
2724            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2725            proc_view: crate::proc_view::tier_proc_view(EnforcementTier::EnvironmentOnly),
2726            host_paths: Vec::new(),
2727            host_path_read_only_binds: Vec::new(),
2728        }
2729    }
2730
2731    /// This enforcement holding a session's runtime directory roots, home folder included, in a
2732    /// fresh directory kept past the test: a clone may outlive the value. For a test whose
2733    /// commands need only to run.
2734    #[cfg(test)]
2735    pub(crate) fn with_test_session_dirs(self) -> Self {
2736        let base = tempfile::tempdir().unwrap().keep();
2737        self.with_runtime_dir(test_runtime_dir(&base), None)
2738    }
2739
2740    /// Run this enforcement's commands in `session_netns` rather than in a namespace of its own.
2741    ///
2742    /// For a plan submitted inside a session: its shell steps are that session's commands, so
2743    /// they share its loopback and its egress proxy.
2744    pub(crate) fn with_session_netns(
2745        mut self,
2746        session_netns: Arc<crate::network_namespace::SessionNetns>,
2747    ) -> Self {
2748        self.session_netns = session_netns;
2749        self
2750    }
2751
2752    /// End the session: kill every process it started, then release its network namespace.
2753    ///
2754    /// Killing goes through the cgroup scope, which every spawn joins before `execve` and no fork
2755    /// escapes, so a server backgrounded with `&`, a `setsid` double fork and a demoted command
2756    /// still running are all in it. The namespace is released only once the scope is empty (or
2757    /// [`SESSION_END_KILL_WAIT`] has passed), so its proxy never stops under a process still
2758    /// using it. Idempotent; every spawn on this enforcement afterwards is refused.
2759    pub(crate) fn end_session(&self) {
2760        self.kill_session_processes();
2761        self.session_netns.end();
2762    }
2763
2764    /// Kill every process in this enforcement's cgroup scope and wait, bounded by
2765    /// [`SESSION_END_KILL_WAIT`], for the scope to empty. Does nothing without a scope.
2766    ///
2767    /// The half of [`Self::end_session`] a plan runs at its own end: a plan's scope is its own,
2768    /// and the namespace may be its session's.
2769    pub(crate) fn kill_session_processes(&self) {
2770        let Some(scope) = self.cgroup_scope.as_ref() else {
2771            return;
2772        };
2773        if let Err(error) = scope.kill_all() {
2774            crate::runtime_err!(
2775                "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
2776                 {error}",
2777                scope.path().display()
2778            );
2779        }
2780        if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
2781            crate::runtime_err!(
2782                "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
2783                 they were killed",
2784                scope.path().display(),
2785                SESSION_END_KILL_WAIT.as_millis()
2786            );
2787        }
2788    }
2789}
2790
2791/// How long [`ShellEnforcement::end_session`] waits for a killed scope's `cgroup.procs` to empty.
2792pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
2793
2794/// Calls [`ShellEnforcement::end_session`] when dropped.
2795///
2796/// Held by a launch for its whole length, so a session that ends by an error return still kills
2797/// what it started. Every path that ends a session normally calls `end_session` itself first, at
2798/// the point its teardown needs it; the call this makes then finds nothing left to do.
2799pub(crate) struct EndSessionOnDrop(ShellEnforcement);
2800
2801impl EndSessionOnDrop {
2802    pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
2803        Self(enforcement)
2804    }
2805}
2806
2807impl Drop for EndSessionOnDrop {
2808    fn drop(&mut self) {
2809        self.0.end_session();
2810    }
2811}
2812
2813/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
2814/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
2815/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
2816/// one or two concise sentences.
2817///
2818/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
2819/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
2820/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
2821/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
2822/// dynamically link, and the only writable paths outside the workdir are the session's temporary
2823/// and home folders, any `read-write` host path ([`crate::host_paths`]) and `/dev/null`, one of the
2824/// three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp
2825/// additionally refuses the `socket(2)` domains in `denied_socket_domains` — closing the
2826/// `/var/run/docker.sock` path, which no Landlock ABI mediates for pathname sockets. The forked
2827/// shell child also drops every Linux capability before `execve` (see
2828/// `linux_enforce::drop_all_capabilities`).
2829/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
2830/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
2831const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
2832resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
2833derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
2834shared libraries — nothing writable), read-only access to the directories an allowlisted \
2835CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
2836trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
2837~/.murmur/config.yaml at its own access, never executable, read-only access to /etc/hosts and \
2838/etc/nsswitch.conf so localhost resolves, read and list access to the session's private /proc \
2839(commands run in the session's own PID namespace and its procfs lists only the session's processes \
2840and the kernel's world-readable files — no host process; where this host cannot mount one, /proc \
2841is the host's and every open beneath it is denied), and no other directory granted \
2842wholesale, plus a fixed device set \
2843every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
2844folders and any read-write host_paths entry, the only writable paths outside the workdir), \
2845/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
2846raw block devices stay denied. The workdir's own grant also \
2847withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
2848capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
2849capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
2850defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
2851profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
2852and the forked shell child \
2853drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
2854denied, not absent: stat, access and readlink on an ungranted path still succeed.";
2855
2856/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
2857/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
2858/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
2859const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
2860host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
2861namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
2862its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
2863writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
2864/dev tmpfs carrying the OCI default device set), with Landlock \
2865and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
2866rather than denied. Commands run in the session's own PID namespace, and /proc is a bind of the \
2867session's private procfs: it lists only the session's own processes and the kernel's \
2868world-readable files, readable and listable, and no host process appears in it or can be \
2869signalled. Where this host cannot mount a private procfs (mur doctor's private proc line), /proc \
2870is a bind of the host's instead, Landlock refuses every open beneath it, and stat(2) on \
2871/proc/<pid> still shows that a host process exists, its owner and its start time.";
2872
2873const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
2874lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
2875kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
2876verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
2877read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
2878mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
2879exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
2880capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
2881rule needs no Landlock and so applies identically on this tier, but it has not been verified \
2882either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
2883profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
2884tier too. The forked shell child still drops every Linux capability and \
2885sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
2886verified either. Treat shell subprocess isolation as experimental on this host.";
2887
2888const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
2889platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
2890enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
2891permanent on this platform.";
2892
2893/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
2894///
2895///   - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
2896///     is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
2897///   - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
2898///     (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
2899///   - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
2900///     sandboxing primitive at all on this platform, and this is permanent. Also still calls
2901///     `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
2902///     nothing is kernel-enforced.
2903///
2904/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
2905/// assert what each tier warns without capturing stderr.
2906pub(crate) fn tier_warning(
2907    tier: EnforcementTier,
2908    shell_allow_is_empty: bool,
2909) -> Option<(&'static str, &'static str)> {
2910    if shell_allow_is_empty {
2911        return None;
2912    }
2913    match tier {
2914        EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
2915        EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
2916        EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
2917        EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
2918        // The launch prints `W-SEC-036` instead, which names what the capsule can reach.
2919        EnforcementTier::Unconfined => None,
2920    }
2921}
2922
2923const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn subprocesses, but \
2924this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
2925tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
2926capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
2927does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
2928This is permanent on this platform, not a placeholder for a future slice.";
2929
2930/// Pure decision for the aggregate-bounding warning, split out of
2931/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
2932/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
2933///
2934/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
2935/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
2936/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
2937/// there is no running session left to warn about.
2938pub(crate) fn aggregate_bounding_warning(
2939    is_linux: bool,
2940    requires_bounding: bool,
2941    has_scope: bool,
2942) -> Option<(&'static str, &'static str)> {
2943    if is_linux || !requires_bounding || has_scope {
2944        return None;
2945    }
2946    Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
2947}
2948
2949/// Fires at every launch, not just once.
2950pub(crate) fn warn_for_missing_aggregate_bounding(
2951    workdir: &crate::workdir_fs::WorkdirRoot,
2952    requires_bounding: bool,
2953    has_scope: bool,
2954) {
2955    let is_linux = cfg!(target_os = "linux");
2956    if let Some((code, message)) =
2957        aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
2958    {
2959        let link = security_warning_link(code);
2960        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2961        crate::agent::append_bootstrap_log(
2962            workdir,
2963            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2964        );
2965    }
2966}
2967
2968/// Fires at every launch, not just once.
2969pub(crate) fn warn_for_enforcement_tier(
2970    tier: EnforcementTier,
2971    workdir: &crate::workdir_fs::WorkdirRoot,
2972    policy: &CapabilityPolicy,
2973) {
2974    if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
2975        let link = security_warning_link(code);
2976        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2977        crate::agent::append_bootstrap_log(
2978            workdir,
2979            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2980        );
2981    }
2982    if tier == EnforcementTier::EnvironmentOnly {
2983        crate::runtime::warn_if_bash_network_bypass(workdir, policy);
2984    }
2985}
2986
2987/// What `prepare_enforcement` hands back alongside the configured `Command`: on a kernel tier, the
2988/// read end of the child's diagnostic pipe.
2989///
2990/// The session's egress proxy is not here. It belongs to the session's
2991/// [`crate::network_namespace::SessionNetns`] and outlives every command, so a process a command
2992/// backgrounds keeps its allowlisted egress after the command returns.
2993#[derive(Debug)]
2994pub(crate) enum SupervisorHandle {
2995    Noop,
2996    #[cfg(target_os = "linux")]
2997    Linux {
2998        /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
2999        /// child's `pre_exec` closure. On a successful `execve` the write end closes
3000        /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
3001        /// setup failure the child writes the real error message here before returning `Err`,
3002        /// letting `execute_shell` fold that legible detail into the error it returns instead of
3003        /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
3004        diag_read: std::os::fd::OwnedFd,
3005    },
3006}
3007
3008impl SupervisorHandle {
3009    /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
3010    /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
3011    /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
3012    /// there is zero cost there.
3013    ///
3014    /// The caller must have already released every *parent-side* copy of the pipe's write end
3015    /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
3016    /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
3017    /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
3018    /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
3019    /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
3020    /// failing).
3021    pub(crate) fn read_diagnostic(&self) -> Option<String> {
3022        match self {
3023            SupervisorHandle::Noop => None,
3024            #[cfg(target_os = "linux")]
3025            SupervisorHandle::Linux { diag_read, .. } => {
3026                use std::os::fd::AsRawFd;
3027                linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3028            }
3029        }
3030    }
3031}
3032
3033// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
3034// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
3035// test builds; production `prepare_enforcement` has no bypass or injection point.
3036#[cfg(test)]
3037thread_local! {
3038    pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3039        const { std::cell::Cell::new(false) };
3040}
3041
3042#[cfg(test)]
3043fn forced_prepare_failure() -> Result<(), String> {
3044    if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3045        return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3046    }
3047    Ok(())
3048}
3049
3050// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
3051// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
3052// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
3053// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
3054// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
3055// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
3056// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
3057// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
3058#[cfg(all(test, target_os = "linux"))]
3059thread_local! {
3060    pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3061        const { std::cell::Cell::new(false) };
3062    pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3063        const { std::cell::Cell::new(false) };
3064    /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
3065    /// only fail on a host that can attempt it at all, and the thing under test — that a
3066    /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
3067    /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
3068    /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
3069    pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3070        const { std::cell::Cell::new(false) };
3071    /// Fails the `/tmp` mapping of a `MountNamespace` spawn at its first step, before any real
3072    /// namespace syscall, so a failed mapping's path is testable on any Linux host.
3073    pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3074        const { std::cell::Cell::new(false) };
3075    /// Fails the private `/proc` mount of a spawn whose view is private, at the mount itself and
3076    /// with `EPERM`, so the fail-closed path is testable on any host that mounts one.
3077    pub(crate) static FORCE_PRIVATE_PROC_FAILURE: std::cell::Cell<bool> =
3078        const { std::cell::Cell::new(false) };
3079}
3080
3081/// RAII arm for [`FORCE_PRIVATE_PROC_FAILURE`].
3082#[cfg(all(test, target_os = "linux"))]
3083pub(crate) struct ForcePrivateProcFailureGuard;
3084
3085#[cfg(all(test, target_os = "linux"))]
3086impl ForcePrivateProcFailureGuard {
3087    pub(crate) fn new() -> Self {
3088        FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(true));
3089        Self
3090    }
3091}
3092
3093#[cfg(all(test, target_os = "linux"))]
3094impl Drop for ForcePrivateProcFailureGuard {
3095    fn drop(&mut self) {
3096        FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(false));
3097    }
3098}
3099
3100/// RAII arm for [`FORCE_TMP_MAP_FAILURE`].
3101#[cfg(all(test, target_os = "linux"))]
3102pub(crate) struct ForceTmpMapFailureGuard;
3103
3104#[cfg(all(test, target_os = "linux"))]
3105impl ForceTmpMapFailureGuard {
3106    pub(crate) fn new() -> Self {
3107        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3108        Self
3109    }
3110}
3111
3112#[cfg(all(test, target_os = "linux"))]
3113impl Drop for ForceTmpMapFailureGuard {
3114    fn drop(&mut self) {
3115        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3116    }
3117}
3118
3119/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
3120/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
3121/// and `runtime`'s (which checks what the dispatch layer does with it).
3122#[cfg(all(test, target_os = "linux"))]
3123pub(crate) struct ForceSealedRootFailureGuard;
3124
3125#[cfg(all(test, target_os = "linux"))]
3126impl ForceSealedRootFailureGuard {
3127    pub(crate) fn new() -> Self {
3128        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3129        Self
3130    }
3131}
3132
3133#[cfg(all(test, target_os = "linux"))]
3134impl Drop for ForceSealedRootFailureGuard {
3135    fn drop(&mut self) {
3136        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3137    }
3138}
3139
3140/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
3141/// half of the sealed mechanism still runs for real against it (the composed root is planned and
3142/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
3143/// which is what makes these tests run identically on a sealed-capable host and on one that could
3144/// never reach `KernelSealed` at all.
3145#[cfg(all(test, target_os = "linux"))]
3146pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3147    // Kept past the test: the enforcement may be cloned into a thread that outlives the value.
3148    let base = tempfile::tempdir().unwrap().keep();
3149    ShellEnforcement {
3150        tier: EnforcementTier::KernelSealed,
3151        ..ShellEnforcement::environment_only().with_test_session_dirs()
3152    }
3153    .with_runtime_dir(test_runtime_dir(&base), None)
3154}
3155
3156/// `runtime/`, `sealed-etc/`, `harness/` and a records directory holding a temporary folder and
3157/// a home folder, created under `base`, held as a session's runtime directory roots.
3158#[cfg(test)]
3159pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3160    let open = |sub: &str| {
3161        let path = base.join(sub);
3162        std::fs::create_dir_all(&path).unwrap();
3163        crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3164    };
3165    crate::runtime_dir::RuntimeDirRoots::new(
3166        open(crate::runtime_dir::RUNTIME_SUBDIR),
3167        open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3168        open(crate::runtime_dir::HARNESS_SUBDIR),
3169    )
3170    .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3171    .with_temp(open(&format!(
3172        "{}/{}",
3173        crate::records_dir::RECORDS_DIR_NAME,
3174        crate::temp_dir::SESSION_TEMP_DIR_NAME
3175    )))
3176    .with_home(open(&format!(
3177        "{}/{}",
3178        crate::records_dir::RECORDS_DIR_NAME,
3179        crate::records_dir::HOME_SUBDIR
3180    )))
3181}
3182
3183/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
3184/// exists — to `command`, so they apply to the spawned process and everything it forks.
3185///
3186/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
3187/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
3188/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. So this is
3189/// the whole of `prepare_enforcement` on `EnvironmentOnly` and off Linux, through which every
3190/// shell command, plan shell step and `murmur:process` program is spawned.
3191///
3192/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
3193/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
3194/// allocation, no locking, no path resolution.
3195#[cfg(unix)]
3196pub(crate) fn attach_process_limits(
3197    command: &mut std::process::Command,
3198    enforcement: &ShellEnforcement,
3199) {
3200    use std::os::unix::process::CommandExt;
3201
3202    if enforcement.tier == EnforcementTier::Unconfined {
3203        attach_unconfined_limits(command, enforcement);
3204        return;
3205    }
3206
3207    let limits = enforcement.resource_limits;
3208    let nproc = enforcement.nproc_bound();
3209    let cgroup_scope = enforcement.cgroup_scope.clone();
3210
3211    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3212    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
3213    // scope exists, a single `write` to a descriptor opened by the parent before the fork —
3214    // no allocation, no locks, no path lookups.
3215    #[allow(unsafe_code)]
3216    unsafe {
3217        command.pre_exec(move || {
3218            crate::resources::apply_hard_rlimits(&limits, nproc)?;
3219            if let Some(scope) = cgroup_scope.as_ref() {
3220                scope.join_current_process()?;
3221            }
3222            Ok(())
3223        });
3224    }
3225}
3226
3227/// [`attach_process_limits`] on [`EnforcementTier::Unconfined`]: only the rlimits
3228/// [`ShellEnforcement::declared_resources`] declares, the cgroup join when a scope exists, and on
3229/// Linux the fd-hygiene step. Nothing else: no `RLIMIT_CORE`, no default ceiling, so an
3230/// undeclared limit is the one `mur run` itself has.
3231///
3232/// Same `pre_exec` restrictions as [`attach_process_limits`].
3233#[cfg(unix)]
3234fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
3235    use std::os::unix::process::CommandExt;
3236
3237    let declared = enforcement.declared_resources;
3238    let nproc = enforcement.nproc_bound();
3239    let cgroup_scope = enforcement.cgroup_scope.clone();
3240
3241    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3242    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs, at most one `write` to
3243    // a descriptor opened by the parent before the fork, and on Linux one `close_range` (or a
3244    // bounded `fcntl` loop) — no allocation, no locks, no path lookups.
3245    #[allow(unsafe_code)]
3246    unsafe {
3247        command.pre_exec(move || {
3248            crate::resources::apply_declared_rlimits(&declared, nproc)?;
3249            if let Some(scope) = cgroup_scope.as_ref() {
3250                scope.join_current_process()?;
3251            }
3252            #[cfg(target_os = "linux")]
3253            linux_enforce::mark_inherited_fds_cloexec()?;
3254            Ok(())
3255        });
3256    }
3257}
3258
3259/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
3260/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
3261///
3262/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles a
3263/// spawn installs *before* `pre_exec` runs exist so the exec'd program inherits them across its
3264/// own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
3265/// stdout/stderr. Marking 0/1/2
3266/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
3267/// invocation, and there is nothing meaningful to reopen them onto: the pipes themselves are the
3268/// intended destination. The gap this constant closes is everything *above*
3269/// stdio, where inheritance was never intended and was only ever prevented by the empirical
3270/// accident of nothing else happening to be open at spawn time.
3271///
3272/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
3273/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
3274/// module carry.
3275#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3276pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3277
3278/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
3279/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
3280/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
3281/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
3282/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
3283/// no kernel sandbox at all.
3284#[cfg(not(target_os = "linux"))]
3285pub(crate) fn prepare_enforcement(
3286    command: &mut std::process::Command,
3287    enforcement: &ShellEnforcement,
3288    _accessible_workdir: &Path,
3289) -> Result<SupervisorHandle, String> {
3290    #[cfg(test)]
3291    forced_prepare_failure()?;
3292
3293    debug_assert!(
3294        matches!(
3295            enforcement.tier,
3296            EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3297        ),
3298        "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
3299    );
3300    attach_process_limits(command, enforcement);
3301    Ok(SupervisorHandle::Noop)
3302}
3303
3304/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
3305/// submodule for the mechanics: the session's network namespace is established in the parent,
3306/// and the `pre_exec` closure sets `RLIMIT_NPROC` or joins the cgroup scope, then joins the
3307/// namespace, then maps `/tmp` or installs the composed root, then seccomp and Landlock, and
3308/// applies the per-process rlimits last.
3309#[cfg(target_os = "linux")]
3310#[allow(unsafe_code)]
3311pub(crate) fn prepare_enforcement(
3312    command: &mut std::process::Command,
3313    enforcement: &ShellEnforcement,
3314    accessible_workdir: &Path,
3315) -> Result<SupervisorHandle, String> {
3316    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3317    use std::os::unix::process::CommandExt;
3318
3319    #[cfg(test)]
3320    forced_prepare_failure()?;
3321
3322    if matches!(
3323        enforcement.tier,
3324        EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3325    ) {
3326        // No kernel sandbox to install. `EnvironmentOnly` keeps the POSIX rlimit ceilings — it is
3327        // only reachable on Linux from tests, and even there it must not read as "unbounded" —
3328        // and `Unconfined` keeps the declared limits and the fd-hygiene step.
3329        attach_process_limits(command, enforcement);
3330        return Ok(SupervisorHandle::Noop);
3331    }
3332
3333    // The session's temporary folder, reopened from its root's held descriptor: its Landlock
3334    // rule's fd on both Landlock tiers, and the identity the sealed bind and the mount-namespace
3335    // bind both verify, so a link swapped onto its path redirects neither.
3336    let temp_dir_fd = enforcement
3337        .runtime_dir
3338        .as_ref()
3339        .and_then(|roots| roots.temp.as_ref())
3340        .map(|temp| temp.path_fd().map(Arc::new))
3341        .transpose()
3342        .map_err(|error| {
3343            format!("sandbox: failed to open the session's temporary folder: {error}")
3344        })?;
3345    // The session's home folder, on exactly the temporary folder's terms: its Landlock rule's fd
3346    // on both Landlock tiers and the identity its sealed bind verifies.
3347    let home_dir_fd = enforcement
3348        .home_root()
3349        .map(|home| home.path_fd().map(Arc::new))
3350        .transpose()
3351        .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3352    // Every host path: opened on both Landlock tiers and on no other, and fail-closed. A configured
3353    // host path that can no longer be opened fails this spawn naming it; it is never dropped.
3354    let host_path_fds: Vec<HostPathFd> = if matches!(
3355        enforcement.tier,
3356        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3357    ) {
3358        enforcement
3359            .host_paths
3360            .iter()
3361            .map(|host_path| {
3362                linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
3363                    fd: Arc::new(fd),
3364                    path: host_path.path.clone(),
3365                    read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
3366                })
3367            })
3368            .collect::<Result<_, _>>()?
3369    } else {
3370        Vec::new()
3371    };
3372
3373    // The descriptors a rule and a bind share on `KernelSealed`: the temporary folder and the
3374    // runtime and records directories, each taken once, with no link followed, from a held
3375    // descriptor. The Landlock rule is taken on the descriptor, and the bind is refused unless
3376    // the directory the child reaches is the one it names, so a link swapped in afterwards
3377    // changes neither.
3378    let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3379        Some(SealedSourceFds::open(
3380            enforcement,
3381            temp_dir_fd.as_ref(),
3382            home_dir_fd.as_ref(),
3383        )?)
3384    } else {
3385        None
3386    };
3387    // On `KernelFull` the runtime directory needs the Landlock rule alone.
3388    let runtime_dir_fd = match &sealed_sources {
3389        Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3390        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3391            .runtime_dir
3392            .as_ref()
3393            .map(|roots| roots.runtime.path_fd().map(Arc::new))
3394            .transpose()
3395            .map_err(|error| {
3396                format!(
3397                    "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3398                )
3399            })?,
3400        None => None,
3401    };
3402    // The records directory takes the runtime directory's rule on both Landlock tiers, from the
3403    // same descriptor its sealed bind verifies.
3404    let records_dir_fd = match &sealed_sources {
3405        Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3406        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3407            .runtime_dir
3408            .as_ref()
3409            .and_then(|roots| roots.records.as_ref())
3410            .map(|records| records.path_fd().map(Arc::new))
3411            .transpose()
3412            .map_err(|error| {
3413                format!(
3414                    "sandbox: failed to open the records directory for Landlock scoping: {error}"
3415                )
3416            })?,
3417        None => None,
3418    };
3419
3420    // The private `/tmp` of a `MountNamespace` spawn, every path and buffer of it built here.
3421    let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3422        (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3423            build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3424        ),
3425        _ => None,
3426    };
3427
3428    // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
3429    // is a normal synchronous error here — it names the path and returns before any subprocess is
3430    // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
3431    // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
3432    // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
3433    // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
3434    let landlock_fds = if matches!(
3435        enforcement.tier,
3436        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3437    ) {
3438        Some(linux_enforce::open_landlock_fds(
3439            linux_enforce::LandlockFdRequest {
3440                accessible_workdir,
3441                landlock_grants: &enforcement.landlock_grants,
3442                device_grants: &landlock_device_grants(enforcement.tier),
3443                workdir_exec: enforcement.workdir_exec,
3444                abi: enforcement.landlock_abi,
3445                sealed: sealed_sources.as_ref(),
3446                temp_dir_fd,
3447                home_dir_fd,
3448                runtime_dir_fd,
3449                records_dir_fd,
3450                host_path_fds: host_path_fds
3451                    .iter()
3452                    .map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
3453                    .collect(),
3454            },
3455        )?)
3456    } else {
3457        None
3458    };
3459
3460    // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
3461    // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
3462    // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
3463    // thread that no longer exists — happens now instead, synchronously, where a failure names
3464    // itself. See `crate::sealed` for the plan/execute split.
3465    let sealed_spec = match &sealed_sources {
3466        Some(sources) => Some(build_sealed_root(
3467            accessible_workdir,
3468            sources,
3469            &enforcement.sealed_bind_dirs,
3470            &enforcement.staged_runtime_dirs,
3471            &host_path_fds,
3472            &enforcement.host_path_read_only_binds,
3473        )?),
3474        None => None,
3475    };
3476
3477    // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
3478    // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
3479    // nothing written (the parent's read then returns immediate EOF — zero cost on the success
3480    // path), while any `pre_exec` setup failure writes its real message here before the closure
3481    // returns `Err`.
3482    let mut diag_fds = [0i32; 2];
3483    // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
3484    // other precondition applies.
3485    let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3486    if rc != 0 {
3487        return Err(format!(
3488            "sandbox: pipe2() for enforcement diagnostics failed: {}",
3489            std::io::Error::last_os_error()
3490        ));
3491    }
3492    // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
3493    // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
3494    let (diag_read, diag_write) = unsafe {
3495        (
3496            OwnedFd::from_raw_fd(diag_fds[0]),
3497            OwnedFd::from_raw_fd(diag_fds[1]),
3498        )
3499    };
3500
3501    // The session's network namespace, created by the first command that needs it and joined by
3502    // every one after. Established here, in the parent and before the fork, so the egress proxy
3503    // is already serving the namespace's listeners when the command's first connection arrives,
3504    // and so a namespace that cannot be created fails this spawn with a legible error instead of
3505    // running the command anywhere else. The join handle moves into the closure, which keeps both
3506    // descriptors open until the `Command` is dropped.
3507    let netns = enforcement.session_netns.establish(
3508        &enforcement.egress_tcp_ports,
3509        crate::egress_proxy::EgressPolicy::new(
3510            enforcement.network_allow_rules.clone(),
3511            enforcement.network_allow_ips.clone(),
3512        ),
3513    )?;
3514
3515    let tier = enforcement.tier;
3516    // Whether this spawn mounts the session's private `/proc`, and whether the `/tmp` mapping
3517    // already entered the private mount namespace it goes in.
3518    let private_proc =
3519        enforcement
3520            .installed_proc_view()
3521            .is_private()
3522            .then_some(linux_enforce::PrivateProc {
3523                mount_namespace_entered: tmp_map_spec.is_some(),
3524            });
3525    // Copied out before the `move` closure below takes ownership of everything it touches, the
3526    // same clone-before-move shape `tier` and the Landlock fds already use.
3527    let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3528    // Same clone-before-move shape. `HostResourceLimits` and `NprocBound` are `Copy`, and the
3529    // `RLIMIT_NPROC` decision is made here because its baseline is a `/proc` walk; the cgroup scope
3530    // is an `Arc` whose `cgroup.procs` descriptor was opened in the parent at scope-creation time,
3531    // so the child performs no path lookup to join it.
3532    let resource_limits = enforcement.resource_limits;
3533    let nproc = enforcement.nproc_bound();
3534    let cgroup_scope = enforcement.cgroup_scope.clone();
3535
3536    // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
3537    // narrow pre_exec window where only async-signal-safe operations are permitted. In order, it
3538    // performs: without a cgroup scope, the `RLIMIT_NPROC` `getrlimit`/`setrlimit` pair of
3539    // `resources::apply_nproc_bound`; with one, one `write` to the `cgroup.procs` descriptor the
3540    // parent opened; two `setns` calls into the session's namespaces; a third `setns` into its PID
3541    // namespace and a `fork`, after which the forked-from process is the relay — `sigprocmask`,
3542    // `close_range` (or `getrlimit` and a `close` loop where it is refused), `waitpid`,
3543    // `sigaction`, `kill` and `_exit`, never returning — and the child
3544    // continues as the command with one `prctl(PR_SET_PDEATHSIG)` and one `getppid`; on a private
3545    // process view the `unshare`/`mount`/`open` of `proc_view::mount_private_proc`; on a
3546    // `MountNamespace` spawn
3547    // the `unshare`/`mount`/`open`/`mkdirat`/`chdir` sequence of `temp_dir::map_private_tmp`, and
3548    // on `KernelSealed` the `unshare`/`open`/`write`/`mount`/`pivot_root` sequence of
3549    // `sealed::construct_composed_root`, both over paths and buffers built here; the
3550    // `close_range` fd-hygiene call; the capability-dropping `prctl`/`capset` sequence
3551    // (bounding-set drop, capset clear, and the explicit `no_new_privs` `prctl`) plus one
3552    // allocation-free `open`/`read`/`close` of `/proc/sys/kernel/cap_last_cap`; libseccomp filter
3553    // construction/load (kernel syscalls); on `KernelFull`/`KernelSealed` Landlock ruleset
3554    // construction/`restrict_self` against already-open fds (also kernel syscalls); and last, the
3555    // `getrlimit`/`setrlimit` pairs of `resources::apply_per_process_rlimits`. No `canonicalize()`, no
3556    // path lookup beyond those prepared paths and `cap_last_cap`, no locks beyond what those
3557    // syscalls need. On any failure it writes the real error message to the CLOEXEC diagnostic
3558    // pipe (best-effort, bounded, raw `write` loop) before returning `Err`. `diag_write`, the
3559    // namespace join handle and the Landlock fds are moved in, and are `CLOEXEC`, so none of them
3560    // reaches the exec'd program.
3561    unsafe {
3562        command.pre_exec(move || {
3563            // `RLIMIT_NPROC` first: the composed root's `unshare(CLONE_NEWUSER)` below fixes the
3564            // session namespace's task bound at this child's limit as of that call, and a later
3565            // `setrlimit` would bound only the new namespace's own tasks. It is set only without
3566            // a cgroup scope (`NprocBound::Headroom`) and creates no descriptor.
3567            if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
3568                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3569                return Err(error);
3570            }
3571            if let Some(scope) = cgroup_scope.as_ref() {
3572                if let Err(error) = scope.join_current_process() {
3573                    linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3574                    return Err(error);
3575                }
3576            }
3577
3578            let (user_ns_fd, net_ns_fd) = netns.raw_fds();
3579            if let Err(error) = linux_enforce::child_install_enforcement(
3580                tier,
3581                linux_enforce::SessionNamespaces {
3582                    user_ns_fd,
3583                    net_ns_fd,
3584                    pid_ns_fd: netns.pid_ns_fd(),
3585                },
3586                private_proc,
3587                tmp_map_spec.as_ref(),
3588                sealed_spec.as_ref(),
3589                landlock_fds.as_ref(),
3590                unix_sockets_allowed,
3591            ) {
3592                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3593                return Err(error);
3594            }
3595
3596            // The per-process rlimits come last, as the final call before `execve`. They cannot
3597            // come first: every descriptor the steps above create (the `/tmp` mapping's `O_PATH`
3598            // fds, the composed root's `/proc/self/*_map` writes and `MkFile` steps,
3599            // `cap_last_cap`, the Landlock ruleset) must be numbered below `RLIMIT_NOFILE`, and
3600            // this child holds every descriptor `mur` had open at the fork, so the lowest free
3601            // number can be above a modest `max_open_files`. Last still bounds the exec'd program
3602            // from its first instruction: a step above that fails returns `Err`, which aborts the
3603            // exec, so no capsule code runs in this child; a failing `setrlimit` aborts it the
3604            // same way; and `prlimit64`/`getrlimit`/`setrlimit` are in
3605            // `SECCOMP_SYSCALL_ALLOWLIST`, and lowering a limit needs no capability, so neither
3606            // the filter nor the capability drop stops them.
3607            if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
3608                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3609                return Err(error);
3610            }
3611            Ok(())
3612        });
3613    }
3614
3615    Ok(SupervisorHandle::Linux { diag_read })
3616}
3617
3618/// Which fixed device set Landlock grants, keyed on the tier.
3619///
3620/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
3621/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
3622/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
3623/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
3624/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
3625///
3626/// The parent opens these paths on the *host*, before the namespace exists; because the composed
3627/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
3628/// are the inodes the capsule reaches.
3629#[cfg(target_os = "linux")]
3630fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
3631    match tier {
3632        EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
3633            .iter()
3634            .map(|device| CapsuleDeviceGrant {
3635                path: device.path,
3636                writable: device.writable,
3637            })
3638            .collect(),
3639        _ => CAPSULE_DEVICE_GRANTS.to_vec(),
3640    }
3641}
3642
3643/// The `/tmp` mapping of one `MountNamespace` spawn: the folder held by `folder_fd` bound over
3644/// canonical `/tmp`, and every path beneath the host's `/tmp` the command still needs re-exposed
3645/// at its own path.
3646///
3647/// Those paths are the accessible workdir and the session's home folder, bound read-write, and the
3648/// runtime and records directories and every Landlock grant path, which already carries the
3649/// `shell.allow` closure and the interpreter and staged runtimes, bound read-only. Landlock adds
3650/// the folder's rights to everything bound beneath it, which are the workdir's own; a read-only
3651/// bind is what keeps them from writing the rest. See [`crate::temp_dir::reexposed_paths`].
3652#[cfg(target_os = "linux")]
3653fn build_tmp_map_spec(
3654    enforcement: &ShellEnforcement,
3655    folder_fd: &std::os::fd::OwnedFd,
3656    accessible_workdir: &Path,
3657) -> Result<crate::temp_dir::TmpMapSpec, String> {
3658    let folder = enforcement.temp_dir_path().ok_or_else(|| {
3659        format!(
3660            "{} this enforcement holds none",
3661            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3662        )
3663    })?;
3664    let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
3665        format!(
3666            "{} fstat of the temporary folder failed: {errno}",
3667            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3668        )
3669    })?;
3670    let canonical_tmp = crate::temp_dir::canonical_tmp();
3671    let read_only = [
3672        enforcement.runtime_dir_path(),
3673        enforcement.records_dir_path(),
3674    ]
3675    .into_iter()
3676    .flatten()
3677    .chain(
3678        enforcement
3679            .landlock_grants
3680            .iter()
3681            .map(|grant| grant.path.as_path()),
3682    );
3683    let reexposed = crate::temp_dir::reexposed_paths(
3684        &canonical_tmp,
3685        std::iter::once(accessible_workdir).chain(enforcement.home_dir_path()),
3686        read_only,
3687    );
3688    crate::temp_dir::TmpMapSpec::new(
3689        folder,
3690        identity,
3691        &canonical_tmp,
3692        &reexposed,
3693        accessible_workdir,
3694    )
3695}
3696
3697/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
3698/// of the host's into the session's `sealed-etc/` directory, returning their host paths in
3699/// [`crate::sealed::SEALED_ETC_PATHS`] order.
3700///
3701/// `sealed-etc/` sits beside the runtime directory, outside every workdir, and no capsule command
3702/// is granted it. The account the files name is the home folder's owner, the identity the
3703/// capsule's subprocesses run as, with the session's home folder as its home directory.
3704///
3705/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
3706/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
3707/// nothing the parent could have done for it.
3708///
3709/// The rewrite is in place ([`crate::workdir_fs::WorkdirRoot::overwrite_in_place`] truncates; it
3710/// does not replace): Landlock grants the file by inode and the composed root binds it by path, so
3711/// replacing the file would leave `/etc/passwd` bound but denied (`EACCES`).
3712#[cfg(target_os = "linux")]
3713fn ensure_sealed_identity_files(
3714    sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
3715    home_root: &crate::workdir_fs::WorkdirRoot,
3716) -> Result<Vec<PathBuf>, String> {
3717    // The home folder was created by this process, so its owner is the identity the capsule's
3718    // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
3719    let (uid, gid) = home_root.owner().map_err(|error| {
3720        format!(
3721            "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
3722        )
3723    })?;
3724    // The same string `build_shell_env` puts in `$HOME`: the home folder's own absolute path,
3725    // which the composed root binds at that same path.
3726    let home = home_root.path().to_string_lossy().into_owned();
3727    let identity = crate::sealed::SealedAccountIdentity {
3728        uid,
3729        gid,
3730        home: &home,
3731    };
3732
3733    let mut written = Vec::new();
3734    for entry in crate::sealed::SEALED_ETC_PATHS {
3735        let Some(file) = entry.synthetic else {
3736            continue;
3737        };
3738        sealed_etc_root
3739            .overwrite_in_place(
3740                file.file_name(),
3741                file.render(&identity)?.as_bytes(),
3742                crate::workdir_fs::DEFAULT_FILE_MODE,
3743            )
3744            .map_err(|error| {
3745                format!(
3746                    "sealed: failed to write the synthetic {}: {error}",
3747                    entry.path
3748                )
3749            })?;
3750        written.push(crate::sealed::synthetic_etc_source(
3751            sealed_etc_root.path(),
3752            file,
3753        ));
3754    }
3755    Ok(written)
3756}
3757
3758/// One host path's descriptor, opened in the parent at each spawn: its Landlock rule's fd on both
3759/// Landlock tiers and, on `KernelSealed`, the identity its bind verifies.
3760#[cfg(target_os = "linux")]
3761pub(crate) struct HostPathFd {
3762    fd: Arc<std::os::fd::OwnedFd>,
3763    path: PathBuf,
3764    read_write: bool,
3765}
3766
3767/// The descriptors a composed root's runtime-provided binds are verified against and its Landlock
3768/// rules are taken on, opened in the parent before the fork.
3769#[cfg(target_os = "linux")]
3770pub(crate) struct SealedSourceFds {
3771    /// The session's temporary folder, reopened from its root's held descriptor: the `/tmp`
3772    /// rule's fd.
3773    tmp_store: Arc<std::os::fd::OwnedFd>,
3774    /// The folder's host path, which the child opens and verifies against [`Self::tmp_store`].
3775    tmp_store_path: PathBuf,
3776    /// The session's home folder, reopened from its root's held descriptor, and its absolute
3777    /// path: its rule's fd and its read-write bind at that same path.
3778    home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
3779    /// The runtime directory, reopened from its root's held descriptor: its rule's fd.
3780    runtime_dir: Arc<std::os::fd::OwnedFd>,
3781    /// The runtime directory's absolute path, which the bind reproduces inside the root and the
3782    /// child verifies against [`Self::runtime_dir`].
3783    runtime_dir_path: PathBuf,
3784    /// The records directory, reopened from its root's held descriptor, and its absolute path:
3785    /// its rule's fd and its read-only bind, on the runtime directory's terms. `None` for an
3786    /// enforcement held outside a session.
3787    records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3788    /// `sealed-etc/`'s host path.
3789    etc_dir: PathBuf,
3790    /// The synthetic `/etc` files, in [`crate::sealed::SEALED_ETC_PATHS`] order.
3791    identity_files: Vec<PathBuf>,
3792}
3793
3794#[cfg(target_os = "linux")]
3795impl SealedSourceFds {
3796    /// Writes the synthetic `/etc` files and takes every descriptor, with `temp_dir_fd` and
3797    /// `home_dir_fd` the descriptors `prepare_enforcement` took on the session's temporary and home
3798    /// folders. A sealed root cannot be composed for an enforcement that holds no runtime
3799    /// directory, no temporary folder or no home folder.
3800    fn open(
3801        enforcement: &ShellEnforcement,
3802        temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3803        home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3804    ) -> Result<Self, String> {
3805        let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
3806            "sealed: a composed root needs the session's runtime directory, and this \
3807             enforcement holds none"
3808                .to_string()
3809        })?;
3810        let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
3811            return Err(
3812                "sealed: a composed root binds the session's temporary folder at /tmp, \
3813                        and this enforcement holds none"
3814                    .to_string(),
3815            );
3816        };
3817        let tmp_store = Arc::clone(tmp_store);
3818        let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
3819            return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
3820        };
3821        let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
3822        let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
3823        let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
3824            format!("sealed: failed to open the runtime directory for its bind: {error}")
3825        })?);
3826        let records_dir = roots
3827            .records
3828            .as_ref()
3829            .map(|records| {
3830                records
3831                    .path_fd()
3832                    .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
3833                    .map_err(|error| {
3834                        format!(
3835                            "sealed: failed to open the records directory for its bind: {error}"
3836                        )
3837                    })
3838            })
3839            .transpose()?;
3840        Ok(Self {
3841            tmp_store,
3842            tmp_store_path: temp.path().to_path_buf(),
3843            home_dir,
3844            runtime_dir,
3845            runtime_dir_path: roots.runtime.path().to_path_buf(),
3846            records_dir,
3847            etc_dir: roots.sealed_etc.path().to_path_buf(),
3848            identity_files,
3849        })
3850    }
3851
3852    /// `(st_dev, st_ino)` of `fd`, which a bind's source must still have in the child.
3853    fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
3854        crate::workdir_fs::fd_identity(fd)
3855            .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
3856    }
3857}
3858
3859/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
3860/// host layout, and lower the plan into the C-string form the forked child executes.
3861///
3862/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
3863/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
3864/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
3865/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
3866/// declared-vs-achieved mismatch.
3867///
3868/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
3869/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
3870/// error would return `Err(String)`, which converts to the ordinary, retryable
3871/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
3872/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
3873/// classification for a capsule that did not get a runtime tree it declared.
3874#[cfg(target_os = "linux")]
3875fn build_sealed_root(
3876    accessible_workdir: &Path,
3877    sources: &SealedSourceFds,
3878    extra_read_only: &[PathBuf],
3879    staged_runtime_read_only: &[PathBuf],
3880    host_paths: &[HostPathFd],
3881    host_path_read_only: &[PathBuf],
3882) -> Result<crate::sealed::SealedRootSpec, String> {
3883    use crate::sealed;
3884
3885    // The workdir must be absolute for its path to mean the same thing inside the composed root,
3886    // and `launch_session` always creates it as one — but the composed root's whole design rests
3887    // on it, so it is checked rather than assumed.
3888    if !accessible_workdir.is_absolute() {
3889        return Err(format!(
3890            "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
3891             its own absolute path",
3892            accessible_workdir.display()
3893        ));
3894    }
3895
3896    let host_path_binds = host_paths
3897        .iter()
3898        .map(|host_path| {
3899            SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
3900                path: host_path.path.clone(),
3901                identity,
3902                read_only: !host_path.read_write,
3903            })
3904        })
3905        .collect::<Result<Vec<_>, _>>()?;
3906    let host_path_dirs: Vec<PathBuf> = host_paths
3907        .iter()
3908        .map(|host_path| host_path.path.clone())
3909        .collect();
3910    let base = sealed::choose_root_base(
3911        accessible_workdir,
3912        &host_path_dirs,
3913        sealed::SEALED_ROOT_BASE_CANDIDATES,
3914        |path| path.is_dir(),
3915    )
3916    .ok_or_else(|| {
3917        format!(
3918            "sealed: no usable base directory for the composed root; none of {:?} exists outside \
3919             the session workdir's own path and every host path's",
3920            sealed::SEALED_ROOT_BASE_CANDIDATES
3921        )
3922    })?;
3923
3924    let plan = sealed::plan_composed_root(
3925        accessible_workdir,
3926        &sealed::SealedBindSources {
3927            tmp_store: (
3928                &sources.tmp_store_path,
3929                SealedSourceFds::identity_of(&sources.tmp_store)?,
3930            ),
3931            etc_dir: &sources.etc_dir,
3932            runtime_dir: Some((
3933                &sources.runtime_dir_path,
3934                SealedSourceFds::identity_of(&sources.runtime_dir)?,
3935            )),
3936            records_dir: sources
3937                .records_dir
3938                .as_ref()
3939                .map(|(fd, path)| {
3940                    SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
3941                })
3942                .transpose()?,
3943            home_dir: Some((
3944                &sources.home_dir.1,
3945                SealedSourceFds::identity_of(&sources.home_dir.0)?,
3946            )),
3947            host_paths: &host_path_binds,
3948            host_path_read_only,
3949        },
3950        &base,
3951        extra_read_only,
3952        staged_runtime_read_only,
3953        &sealed::RealHostLayout,
3954    );
3955    sealed::build_sealed_root_spec(&plan)
3956}
3957
3958/// Linux-only mechanics: the `pre_exec` enforcement sequence, seccomp filter construction and
3959/// Landlock application.
3960///
3961/// Nothing here uses `libseccomp`'s notify facility. `connect`/`sendto` are enforced by the
3962/// network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which decide
3963/// from kernel-held state. A `Notify` rule would reintroduce a `/proc/<pid>/mem` read on the hot
3964/// path, where the value read can change between the decision and the kernel's own dereference.
3965#[cfg_attr(target_os = "linux", allow(unsafe_code))]
3966#[cfg(target_os = "linux")]
3967mod linux_enforce {
3968    use std::io;
3969    use std::os::fd::{AsFd, OwnedFd, RawFd};
3970    use std::path::Path;
3971
3972    use landlock::{make_bitflags, AccessFs, BitFlags};
3973
3974    use super::{EnforcementTier, LandlockGrant};
3975
3976    /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
3977    /// naming the failed step is far shorter than this; the bound just keeps the best-effort
3978    /// `write`/`read` loops trivially terminating.
3979    const MAX_DIAG_LEN: usize = 1024;
3980
3981    /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
3982    /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
3983    /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
3984    /// never a path to re-open there.
3985    pub(super) struct OpenLandlockGrant {
3986        fd: OwnedFd,
3987        list_dir: bool,
3988        executable: bool,
3989    }
3990
3991    /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
3992    /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
3993    /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
3994    /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
3995    /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
3996    /// their bits must not be confusable.
3997    pub(super) struct OpenDeviceGrant {
3998        fd: OwnedFd,
3999        writable: bool,
4000    }
4001
4002    /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
4003    /// workdir's fd and the session's temporary folder's (both scoped by
4004    /// [`workdir_access_rights`]), each successfully-opened grant fd, and each
4005    /// successfully-opened fixed-device fd.
4006    /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
4007    /// against these fds without performing a single `open()`.
4008    pub(super) struct LandlockChildFds {
4009        workdir_fd: OwnedFd,
4010        /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
4011        /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
4012        /// but its argument — has the whole workdir rule decided for it before fork.
4013        workdir_exec: bool,
4014        /// The Landlock ABI the ruleset is built at, decided in the parent from the session's
4015        /// [`super::HostProbe`] (see [`super::ShellEnforcement::landlock_abi`]) so the child reads
4016        /// no host state of its own. `None` builds at ABI v1, the narrowest set.
4017        abi: Option<u8>,
4018        /// The session's temporary folder, `tmp/` in its records directory, on both Landlock
4019        /// tiers whenever the enforcement holds one, mapped onto `/tmp` or not. `None` outside a
4020        /// session. On `KernelSealed` the same descriptor is the composed root's `/tmp` bind
4021        /// source.
4022        ///
4023        /// It needs a rule of its own on every mapped tier: Landlock resolves a path by walking
4024        /// dentries up to each mount root, so an access to `/tmp/x` is matched against the folder
4025        /// (the bind's root) and then against the mountpoint `/tmp` — never against the records
4026        /// directory or the workdir. It carries the workdir's own right-set, `Execute` axis
4027        /// included, so `workdir_exec: false` forbids running anything written there.
4028        temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4029        /// The session's home folder, `home/` in its records directory, on exactly
4030        /// [`Self::temp_dir_fd`]'s terms: the workdir's rights, on both Landlock tiers, whenever the
4031        /// enforcement holds one. On `KernelSealed` the same descriptor is the read-write bind's
4032        /// source.
4033        home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4034        /// The synthetic `/etc/passwd`/`/etc/group` in the session's `sealed-etc/`, opened on
4035        /// `KernelSealed` and empty on every other tier.
4036        ///
4037        /// Exactly the [`Self::temp_dir_fd`] problem, one directory over. The grant list already
4038        /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
4039        /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
4040        /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
4041        /// at the bound file's own inode, so without these fds both account databases would be
4042        /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
4043        ///
4044        /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
4045        /// nothing inside a capsule has any business editing an account database.
4046        sealed_identity_fds: Vec<OwnedFd>,
4047        /// The session's runtime directory, reopened from its root's held descriptor: `ReadFile`
4048        /// and `ReadDir` and nothing else, so every command reads `MURMUR.md`, `task.md`, plans
4049        /// and installed tools and changes none of them. `None` outside a session. On
4050        /// `KernelSealed` the same descriptor is the read-only bind's source.
4051        runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4052        /// The session's records directory, reopened from its root's held descriptor, granted on
4053        /// [`Self::runtime_dir_fd`]'s terms: every command reads the trace and logs and changes
4054        /// none of them. `None` outside a session. On `KernelSealed` the same descriptor is the
4055        /// read-only bind's source.
4056        records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4057        /// Every host path's descriptor and whether it is `read-write`, in config order: one rule
4058        /// each, with [`host_path_access_rights`]. Opened fail-closed by `prepare_enforcement`.
4059        host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
4060        grants: Vec<OpenLandlockGrant>,
4061        devices: Vec<OpenDeviceGrant>,
4062    }
4063
4064    impl LandlockChildFds {
4065        /// The temporary folder's fd, for the tier-gate test. Keeps the struct's fields private
4066        /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
4067        #[cfg(test)]
4068        pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
4069            self.temp_dir_fd.as_deref()
4070        }
4071
4072        /// The home folder's fd, for the tier-gate test, on the same terms.
4073        #[cfg(test)]
4074        pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
4075            self.home_dir_fd.as_deref()
4076        }
4077
4078        /// The runtime directory's fd, for the tier-gate test, on the same terms.
4079        #[cfg(test)]
4080        pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
4081            self.runtime_dir_fd.as_deref()
4082        }
4083
4084        /// The records directory's fd, for the tier-gate test, on the same terms.
4085        #[cfg(test)]
4086        pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
4087            self.records_dir_fd.as_deref()
4088        }
4089
4090        /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
4091        /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
4092        #[cfg(test)]
4093        pub(super) fn sealed_identity_fd_count(&self) -> usize {
4094            self.sealed_identity_fds.len()
4095        }
4096    }
4097
4098    /// The Landlock rights granted on the capsule **workdir's own** `PathBeneath` rule,
4099    /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
4100    /// declared `capabilities.filesystem.workdir_exec: true`.
4101    ///
4102    /// Spelled out variant by variant rather than `AccessFs::from_all` for two reasons: `from_all`
4103    /// hides what is actually handed to the capsule, and four rights are withheld from this
4104    /// constant. It is the set for [`super::LANDLOCK_TARGET_ABI`]; `apply_landlock_scope` masks it
4105    /// to the ABI the ruleset is built at, so on an older kernel the rights that ABI does not know
4106    /// simply drop out.
4107    ///
4108    /// Two rights are granted from an ABI later than v1:
4109    ///
4110    ///   - `Refer` (ABI v2, Linux 5.19) — moving and hard-linking a file between two folders.
4111    ///     Landlock refuses every cross-folder `rename(2)`/`link(2)` with `EXDEV` unless the ruleset
4112    ///     handles `Refer` and both folders grant it, so without it every tool that renames a
4113    ///     finished file into place from a sibling folder fails in the workdir. It reaches nothing
4114    ///     outside: a move or link needs `Refer` on both the source and the destination folder, no
4115    ///     rule outside the workdir and the session's temporary folder carries it, and Landlock
4116    ///     also refuses any move that would leave the file with more rights at its destination
4117    ///     than its source.
4118    ///   - `Truncate` (ABI v3, Linux 6.2) — `truncate(2)`, `ftruncate(2)` and `open(O_TRUNC)`.
4119    ///     Below v3 Landlock does not mediate truncation at all, so a capsule could zero any host
4120    ///     file its uid can write. Handling it denies that everywhere except the rules that grant
4121    ///     it, which are exactly the ones already carrying `WriteFile`: this one and the writable
4122    ///     devices.
4123    ///
4124    ///   - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
4125    ///     makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
4126    ///     on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
4127    ///     cannot run under any name — including an allowlisted basename, the exact bypass a prior
4128    ///     release shipped. The cost: a binary the capsule legitimately compiled in its workdir
4129    ///     cannot run either, which is what `workdir_exec: true` buys back.
4130    ///   - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
4131    ///     whole scope. A capsule running as root could `mknod` a node for the host's own disk
4132    ///     (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
4133    ///     permits, because the new inode lives beneath a granted path — then `open()` it and
4134    ///     read the raw host filesystem underneath every other restriction. No known workload
4135    ///     creates device nodes in its working tree.
4136    ///   - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
4137    ///     filesystem object with a kernel-side identity beyond a regular file), but unlike the
4138    ///     two device rights this one is a genuine open question: some build tooling and some
4139    ///     language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
4140    ///     that breaks those is not a fix. The manual acceptance procedure in
4141    ///     `docs/content/reference/workdir-device-node-manual-verification.md` carries an
4142    ///     explicit unix-socket scenario for exactly this; if the team's
4143    ///     real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
4144    ///
4145    /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
4146    /// and a FIFO carries none of the raw-device risk above.
4147    ///
4148    /// Because `apply_landlock_scope`'s `handle_access` declares the *full* `from_all` set of the
4149    /// ABI it builds at, a right this rule withholds is not merely "not extra-granted" — it
4150    /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
4151    /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
4152    /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
4153    /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
4154    /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
4155    /// nowhere the capsule writes.
4156    pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4157        WriteFile
4158            | ReadFile
4159            | ReadDir
4160            | RemoveDir
4161            | RemoveFile
4162            | MakeDir
4163            | MakeReg
4164            | MakeFifo
4165            | MakeSym
4166            | Refer
4167            | Truncate
4168    });
4169
4170    /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
4171    /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
4172    ///
4173    /// A function rather than a second constant so the two cases cannot drift: adding a right to
4174    /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
4175    /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
4176    /// The whole point of the default is that the workdir grant is no longer name-based at all.
4177    pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4178        if workdir_exec {
4179            WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4180        } else {
4181            WORKDIR_ACCESS_RIGHTS_NO_EXEC
4182        }
4183    }
4184
4185    /// A host path's `PathBeneath` rights: `ReadFile | ReadDir` for `read`, and
4186    /// [`workdir_access_rights`]`(false)` for `read-write`.
4187    /// Never `Execute`, whatever `workdir_exec` grants the workdir: a host path makes files
4188    /// reachable, never runnable, so `shell.allow` stays the whole list of what can run.
4189    pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
4190        if read_write {
4191            workdir_access_rights(false)
4192        } else {
4193            AccessFs::ReadFile | AccessFs::ReadDir
4194        }
4195    }
4196
4197    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock.
4198    ///
4199    /// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the version and
4200    /// creates nothing — no ruleset fd and no domain — so unlike
4201    /// [`probe_landlock_full_access`] it is safe to call in-process. A kernel built without
4202    /// Landlock, or with it disabled at boot, fails it with `ENOSYS`/`EOPNOTSUPP`.
4203    pub(super) fn kernel_landlock_abi() -> Option<u8> {
4204        const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4205        // SAFETY: with a null attribute pointer, a zero size and only the VERSION flag set, the
4206        // kernel reads no memory and allocates no fd; the call has no side effect to undo.
4207        let version = unsafe {
4208            libc::syscall(
4209                libc::SYS_landlock_create_ruleset,
4210                std::ptr::null::<libc::c_void>(),
4211                0usize,
4212                LANDLOCK_CREATE_RULESET_VERSION,
4213            )
4214        };
4215        if version <= 0 {
4216            return None;
4217        }
4218        Some(u8::try_from(version).unwrap_or(u8::MAX))
4219    }
4220
4221    /// Probes real Landlock support by building a ruleset granting full access to `/` and
4222    /// calling `restrict_self()` **in a forked child**, so the host process is never placed
4223    /// into a Landlock domain by a mere capability check.
4224    ///
4225    /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
4226    /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
4227    /// mount family to the task and every descendant it will ever have, which is how Landlock
4228    /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
4229    /// before the sealed probe, and a session's probe is taken before any fork that would
4230    /// build a composed root, so restricting in-process poisons `sealed` on every host and every
4231    /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
4232    /// to suggest the runtime did it to itself.
4233    ///
4234    /// The ruleset is built in the parent because creating one and adding rules restricts
4235    /// nothing; only `restrict_self` applies it. That keeps everything the child does after
4236    /// the fork down to a `prctl` and one syscall.
4237    pub(super) fn probe_landlock_full_access() -> Option<bool> {
4238        use landlock::{
4239            Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4240            RulesetCreatedAttr, RulesetStatus, ABI,
4241        };
4242
4243        // The child reports which of the three outcomes it reached through its exit status;
4244        // nothing else crosses the fork.
4245        const PROBE_FULLY_ENFORCED: i32 = 0;
4246        const PROBE_PARTIAL: i32 = 1;
4247        const PROBE_FAILED: i32 = 2;
4248
4249        // ABI v1 on purpose: this decides whether a Landlock tier is available at all, which v1
4250        // answers on every Landlock kernel. The ABI a capsule's ruleset is built at is read
4251        // separately, by `kernel_landlock_abi`.
4252        let abi = ABI::V1;
4253        let access_all = AccessFs::from_all(abi);
4254
4255        let root_fd = PathFd::new("/").ok()?;
4256
4257        let ruleset = Ruleset::default()
4258            .set_compatibility(CompatLevel::BestEffort)
4259            .handle_access(access_all)
4260            .ok()?
4261            .create()
4262            .ok()?
4263            .add_rule(PathBeneath::new(root_fd, access_all))
4264            .ok()?;
4265
4266        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
4267        // confines itself to async-signal-safe work. Everything this child does is the
4268        // `restrict_self` syscall pair and `_exit`; the allocating part of building the
4269        // ruleset already happened above, in the parent.
4270        let pid = unsafe { libc::fork() };
4271        if pid < 0 {
4272            return None;
4273        }
4274        if pid == 0 {
4275            let code = match ruleset.restrict_self() {
4276                Ok(status) => {
4277                    if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4278                        PROBE_FULLY_ENFORCED
4279                    } else {
4280                        PROBE_PARTIAL
4281                    }
4282                }
4283                Err(_) => PROBE_FAILED,
4284            };
4285            // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4286            // which is what keeps the parent's state untouched.
4287            unsafe { libc::_exit(code) }
4288        }
4289
4290        let mut wait_status: libc::c_int = 0;
4291        // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
4292        let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4293        if waited < 0 || !libc::WIFEXITED(wait_status) {
4294            return None;
4295        }
4296        match libc::WEXITSTATUS(wait_status) {
4297            PROBE_FULLY_ENFORCED => Some(true),
4298            PROBE_PARTIAL => Some(false),
4299            // A `restrict_self` that errored tells us nothing either way, which is the same
4300            // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
4301            _ => None,
4302        }
4303    }
4304
4305    /// Runs inside the forked child, pre-exec: joins the session's user and network namespaces,
4306    /// enters its PID namespace through a relay (this process waits outside it and never returns;
4307    /// the command continues in a child forked into it), maps `/tmp` onto the session's
4308    /// temporary folder when `tmp_map_spec` is given, mounts the session's private `/proc` when
4309    /// `private_proc` is given, strips the child's Linux capabilities, installs the seccomp filter
4310    /// (the `socket()` domain denials keyed on `unix_sockets_allowed`, over a default-deny syscall
4311    /// allowlist), and (on `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope —
4312    /// which is also what enforces `capabilities.shell.allow`. Returning `Err` here aborts the
4313    /// exec (std's `Command` machinery propagates it back to the parent's `.spawn()` call as an
4314    /// `io::Error`) — the fail-closed path for setup failures that happen after fork.
4315    pub(super) fn child_install_enforcement(
4316        tier: EnforcementTier,
4317        namespaces: SessionNamespaces,
4318        private_proc: Option<PrivateProc>,
4319        tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4320        sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4321        landlock_fds: Option<&LandlockChildFds>,
4322        unix_sockets_allowed: bool,
4323    ) -> io::Result<()> {
4324        // FIRST, on every kernel tier: join the session's network namespace. It has to precede
4325        // everything below it for three separate reasons:
4326        //
4327        //   * `SECCOMP_MUST_STAY_DENIED` denies `setns`, so the filter installed further down
4328        //     closes the only window in which the join can happen — the same argument the
4329        //     composed root makes for `unshare`.
4330        //   * joining the user namespace hands this child every capability *inside* it, and
4331        //     `drop_all_capabilities` (below) strips them again before `execve`. Joining after
4332        //     that drop would fail: entering the network namespace needs `CAP_SYS_ADMIN` there.
4333        //   * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests its user
4334        //     namespace inside the session's. A task in a descendant user namespace holds no
4335        //     capability in the ancestor, so the capsule cannot reconfigure the network namespace
4336        //     it is confined to — which only holds if the join comes first.
4337        //
4338        // There is no path that skips it and falls back: a host that cannot create the namespace
4339        // was refused at launch (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`), and a
4340        // spawn whose namespace could not be established failed before the fork.
4341        //
4342        // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
4343        // documents itself as running in, and the one `join_session_netns` requires. Both
4344        // descriptors are held open by the join handle the `pre_exec` closure owns.
4345        unsafe {
4346            crate::network_namespace::join_session_netns(
4347                namespaces.user_ns_fd,
4348                namespaces.net_ns_fd,
4349            )
4350        }
4351        .map_err(|error| {
4352            io::Error::other(format!(
4353                "sandbox: joining this session's network namespace failed: {error}"
4354            ))
4355        })?;
4356
4357        // NEXT: the session's PID namespace. This process becomes the command's relay, which
4358        // stays outside the namespace and never returns from here; the process that does return
4359        // is its child, forked into the namespace, and every step below runs in it. `setns` of
4360        // a PID namespace needs `CAP_SYS_ADMIN` in the user namespace owning it, which the join
4361        // above just handed out, and the seccomp filter below denies `setns`.
4362        //
4363        // SAFETY: post-`fork()`, pre-exec child context, inside the session's user namespace; the
4364        // descriptor is held open by the join handle the `pre_exec` closure owns.
4365        unsafe { enter_session_pid_namespace(namespaces.pid_ns_fd) }?;
4366
4367        // NEXT, and only on a `MountNamespace` spawn: a private mount namespace, owned by the
4368        // session's user namespace, in which the session's temporary folder is bound over `/tmp`.
4369        // Here for the composed root's reason: the capabilities the join above handed out are
4370        // still held, and the seccomp filter below denies `unshare` and `mount`. A mapping that
4371        // cannot be built fails the spawn; nothing runs with an unmapped `/tmp` instead.
4372        if let Some(spec) = tmp_map_spec {
4373            #[cfg(test)]
4374            if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4375                return Err(io::Error::other(format!(
4376                    "{} (forced by test seam)",
4377                    spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4378                )));
4379            }
4380            // SAFETY: post-`fork()`, pre-exec child context, after `join_session_netns` handed
4381            // this child every capability in the session's user namespace and before the seccomp
4382            // filter — the window `map_private_tmp` requires. Every path and buffer it reads was
4383            // built in the parent and is borrowed from the `pre_exec` closure.
4384            unsafe { crate::temp_dir::map_private_tmp(spec) }
4385                .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4386        }
4387
4388        // NEXT, when the session's view is private: a `procfs` of the session's PID namespace
4389        // over `/proc`, in the private mount namespace the `/tmp` mapping entered or one entered
4390        // here. Before the composed root, whose `/proc` step then binds this mount, and before the
4391        // filter, which denies `unshare` and `mount`. The staging probe mounted one, so a mount
4392        // that fails here fails the spawn; nothing runs with the host's `/proc` instead.
4393        let proc_fd = match private_proc {
4394            None => None,
4395            Some(private_proc) => {
4396                #[cfg(test)]
4397                if super::FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.get()) {
4398                    return Err(io::Error::other(format!(
4399                        "{} (forced by test seam)",
4400                        crate::proc_view::describe_mount_failure(
4401                            crate::proc_view::ProcMountStep::MountProc,
4402                            libc::EPERM
4403                        )
4404                    )));
4405                }
4406                // SAFETY: post-`fork()`, pre-exec child context, inside the session's user and
4407                // PID namespaces with the capabilities the join handed out, before the filter.
4408                let fd = unsafe {
4409                    crate::proc_view::mount_private_proc(!private_proc.mount_namespace_entered)
4410                }
4411                .map_err(|(step, errno)| {
4412                    io::Error::other(crate::proc_view::describe_mount_failure(step, errno))
4413                })?;
4414                // SAFETY: `fd` was just returned by a successful `open` and nothing else owns it.
4415                Some(unsafe { <OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd) })
4416            }
4417        };
4418
4419        // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
4420        // composed root. This has to precede every *remaining* step in this closure, because
4421        // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
4422        // the filter below covers, permanently and deliberately.
4423        // The composed root is therefore built by this process while it still has its pre-filter
4424        // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
4425        // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
4426        // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
4427        // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
4428        // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
4429        if tier == EnforcementTier::KernelSealed {
4430            if let Some(spec) = sealed_spec {
4431                #[cfg(test)]
4432                if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4433                    return Err(io::Error::other(format!(
4434                        "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4435                        crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4436                    )));
4437                }
4438                crate::sealed::construct_composed_root(spec)
4439                    .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4440            }
4441        }
4442
4443        // Before anything else in this window, on every kernel tier: shut the fd-inheritance
4444        // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
4445        // ruleset installed further down this function — it can do nothing about a descriptor
4446        // the child already holds open at that point, because that fd was opened before the
4447        // ruleset existed and there is no operation left for the ruleset to intercept. Until
4448        // this call existed, the only thing stopping a spawned shell from inheriting an
4449        // arbitrary open fd was the empirical accident that nothing else happened to be open in
4450        // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
4451        // config read, a lock file, a log handle — would have leaked silently into every
4452        // subsequent subprocess, on every platform and every tier, with no test to catch it.
4453        //
4454        // Running it here is also what makes it cover this function's *own* descriptors: the
4455        // diagnostic pipe and the Landlock grant fds are used below but must never survive the
4456        // `execve`, and neither may the session's namespace handles, already used above.
4457        mark_inherited_fds_cloexec()?;
4458
4459        // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
4460        // capability model are independent, both-must-allow gates, so narrowing the Landlock
4461        // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
4462        // must also run *before* `install_seccomp_filter`, because it is what sets
4463        // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
4464        // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
4465        // failure is its own distinct, fail-closed error path before any filter is installed.
4466        drop_all_capabilities()?;
4467
4468        // The child stays *non-dumpable*, inherited from the runtime process
4469        // (`security::harden_process_dumpable`). Nothing reads the child's memory, so nothing
4470        // needs it dumpable. Marking it dumpable opens same-uid `ptrace`, `environ` and core-dump
4471        // access for the whole life of every shell subprocess: do not do so without a reader that
4472        // requires it.
4473        install_seccomp_filter(unix_sockets_allowed)?;
4474
4475        if matches!(
4476            tier,
4477            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4478        ) {
4479            if let Some(fds) = landlock_fds {
4480                apply_landlock_scope(fds, proc_fd.as_ref()).map_err(io::Error::other)?;
4481            }
4482        }
4483
4484        Ok(())
4485    }
4486
4487    /// The session's namespace descriptors a command enters, all held open by the join handle
4488    /// the `pre_exec` closure owns.
4489    #[derive(Debug, Clone, Copy)]
4490    pub(super) struct SessionNamespaces {
4491        pub(super) user_ns_fd: RawFd,
4492        pub(super) net_ns_fd: RawFd,
4493        pub(super) pid_ns_fd: RawFd,
4494    }
4495
4496    /// A spawn that mounts the session's private `/proc`.
4497    #[derive(Debug, Clone, Copy)]
4498    pub(super) struct PrivateProc {
4499        /// Whether the `/tmp` mapping already entered a private mount namespace.
4500        pub(super) mount_namespace_entered: bool,
4501    }
4502
4503    /// The exit status a command forked into the session's PID namespace leaves when it finds
4504    /// its relay already gone, before running anything.
4505    const RELAY_GONE_EXIT: i32 = 125;
4506
4507    /// Enters the session's PID namespace for the command: `setns(pid_ns_fd, CLONE_NEWPID)`, then
4508    /// `fork()`. Returns only in the forked child — the command, inside the namespace — and only
4509    /// once it has made its relay's death its own (`PR_SET_PDEATHSIG`) and seen the relay alive.
4510    /// The calling process becomes the relay ([`run_relay`]) and never returns.
4511    ///
4512    /// The command stays in the relay's process group and cgroup: both are inherited across the
4513    /// fork. A live relay is outside the namespace, so `getppid()` reads `0` in the command; any
4514    /// other value is the namespace's holder, which inherited the command because the relay
4515    /// already died, and the command exits before running anything.
4516    ///
4517    /// # Safety
4518    /// Post-`fork()`, pre-exec child context only, after `join_session_netns`.
4519    unsafe fn enter_session_pid_namespace(pid_ns_fd: RawFd) -> io::Result<()> {
4520        // SAFETY: the caller's contract; every call takes integers or live stack locals, and the
4521        // relay branch never returns.
4522        unsafe {
4523            if libc::setns(pid_ns_fd, libc::CLONE_NEWPID) != 0 {
4524                return Err(io::Error::other(format!(
4525                    "sandbox: joining this session's PID namespace failed: {}",
4526                    io::Error::last_os_error()
4527                )));
4528            }
4529            // The relay `waitpid`s its command, which an ignored `SIGCHLD` would turn into
4530            // `ECHILD` once the kernel had reaped it.
4531            let mut default: libc::sigaction = std::mem::zeroed();
4532            default.sa_sigaction = libc::SIG_DFL;
4533            libc::sigaction(libc::SIGCHLD, &default, std::ptr::null_mut());
4534            let command = libc::fork();
4535            if command < 0 {
4536                return Err(io::Error::other(format!(
4537                    "sandbox: forking the command into this session's PID namespace failed: {}",
4538                    io::Error::last_os_error()
4539                )));
4540            }
4541            if command > 0 {
4542                run_relay(command);
4543            }
4544            if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
4545                return Err(io::Error::other(format!(
4546                    "sandbox: prctl(PR_SET_PDEATHSIG) in the command failed: {}",
4547                    io::Error::last_os_error()
4548                )));
4549            }
4550            if libc::getppid() != 0 {
4551                libc::_exit(RELAY_GONE_EXIT);
4552            }
4553        }
4554        Ok(())
4555    }
4556
4557    /// The relay's whole life: the process `std::process::Child` refers to, outside the session's
4558    /// PID namespace, which runs no capsule code and holds no descriptor.
4559    ///
4560    /// It blocks every signal (a process-group signal reaches the command directly, and `SIGKILL`
4561    /// still ends the relay, which `PR_SET_PDEATHSIG` turns into the command's end), closes every
4562    /// descriptor — `std`'s exec-status pipe, the diagnostic pipe, stdio and the holder's
4563    /// lifeline among them, so none stays open on the relay's account, even where `close_range`
4564    /// is refused (`cgroup::close_range`) — and waits for the
4565    /// command. It then leaves the command's status as its own: the exit code, or the
4566    /// terminating signal re-raised with its default disposition, falling back to `128 + sig`.
4567    /// `wait4` on the relay reports the command's resource use with its own, since Linux counts a
4568    /// reaped child's waited-for children.
4569    ///
4570    /// # Safety
4571    /// Post-`fork()` child context only; every call is async-signal-safe and nothing allocates.
4572    unsafe fn run_relay(command: libc::pid_t) -> ! {
4573        // SAFETY: the caller's contract; every call takes integers or live stack locals.
4574        unsafe {
4575            let mut all: libc::sigset_t = std::mem::zeroed();
4576            libc::sigfillset(&mut all);
4577            libc::sigprocmask(libc::SIG_SETMASK, &all, std::ptr::null_mut());
4578            crate::cgroup::close_range(0, libc::c_uint::MAX);
4579            let mut status: libc::c_int = 0;
4580            loop {
4581                let waited = libc::waitpid(command, &mut status, 0);
4582                if waited == command {
4583                    break;
4584                }
4585                if waited < 0 && *libc::__errno_location() == libc::EINTR {
4586                    continue;
4587                }
4588                libc::_exit(1);
4589            }
4590            if libc::WIFEXITED(status) {
4591                libc::_exit(libc::WEXITSTATUS(status));
4592            }
4593            if libc::WIFSIGNALED(status) {
4594                let signal = libc::WTERMSIG(status);
4595                let mut default: libc::sigaction = std::mem::zeroed();
4596                default.sa_sigaction = libc::SIG_DFL;
4597                libc::sigaction(signal, &default, std::ptr::null_mut());
4598                let mut only: libc::sigset_t = std::mem::zeroed();
4599                libc::sigemptyset(&mut only);
4600                libc::sigaddset(&mut only, signal);
4601                libc::sigprocmask(libc::SIG_UNBLOCK, &only, std::ptr::null_mut());
4602                libc::kill(libc::getpid(), signal);
4603                libc::_exit(128 + signal);
4604            }
4605            libc::_exit(1)
4606        }
4607    }
4608
4609    /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
4610    /// the only descriptors that survive into the exec'd program are the ones the runtime
4611    /// deliberately set up (stdio) rather than whatever happened to be open.
4612    ///
4613    /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
4614    /// the required semantics here, not a softer variant of it: several fds in this range are
4615    /// still needed *inside* this `pre_exec` window — the diagnostic pipe a failure further down
4616    /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules from. Closing
4617    /// them here would break the very enforcement setup that follows; flagging them means they
4618    /// keep working until `execve`, which then closes them for us.
4619    ///
4620    /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
4621    /// stdio decision and its reasoning.
4622    ///
4623    /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
4624    /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
4625    /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
4626    /// so every spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
4627    /// now fails rather than silently running without fd hygiene. That is deliberate and it is
4628    /// this module's established fail-closed discipline (`drop_all_capabilities`,
4629    /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
4630    /// error rather than degrade), but it is a real, user-visible narrowing of the supported
4631    /// kernel range for kernel-enforcement tiers.
4632    pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
4633        // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
4634        // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
4635        // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
4636        // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
4637        // spelling from `close_range(2)`.
4638        let rc = unsafe {
4639            libc::syscall(
4640                libc::SYS_close_range,
4641                super::FD_HYGIENE_FIRST_FD,
4642                libc::c_uint::MAX,
4643                libc::CLOSE_RANGE_CLOEXEC,
4644            )
4645        };
4646        if rc != 0 {
4647            return Err(io::Error::last_os_error());
4648        }
4649        Ok(())
4650    }
4651
4652    /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
4653    /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
4654    /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
4655    /// only capabilities 0–31 and make the kernel log a deprecation warning.
4656    const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
4657
4658    /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
4659    /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
4660    /// capabilities — so this can never under-cover. Numbers the running kernel does not define
4661    /// return `EINVAL`, which the loop skips.
4662    const CAP_LAST_CAP_FALLBACK: u32 = 63;
4663
4664    /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
4665    /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
4666    /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
4667    /// `msghdr`/`cmsghdr` handling.
4668    ///
4669    /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
4670    /// handed to `capset(2)`; Rust itself only ever writes them.
4671    #[repr(C)]
4672    #[allow(dead_code)]
4673    struct CapUserHeader {
4674        version: u32,
4675        pid: libc::c_int,
4676    }
4677
4678    /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
4679    /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
4680    /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
4681    /// note.
4682    #[repr(C)]
4683    #[derive(Clone, Copy)]
4684    #[allow(dead_code)]
4685    struct CapUserData {
4686        effective: u32,
4687        permitted: u32,
4688        inheritable: u32,
4689    }
4690
4691    /// Strips every Linux capability from the forked child before `execve()`, in three steps
4692    /// whose **order matters**:
4693    ///
4694    ///   1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
4695    ///      still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
4696    ///      `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
4697    ///   2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
4698    ///      Shrinking your own permitted set to any subset — including empty — never requires
4699    ///      `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
4700    ///      inheritable also empties the *ambient* set, which the kernel maintains as a subset of
4701    ///      both — that is what covers a non-root capsule handed an unexpected ambient
4702    ///      `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
4703    ///   3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
4704    ///      file-capability binary, and so the seccomp filter installed next can load without
4705    ///      `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
4706    ///      longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
4707    ///      as a side effect.
4708    ///
4709    /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
4710    /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
4711    /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
4712    /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
4713    /// genuinely unprivileged caller, the bounding set would silently survive while this function
4714    /// still reported success.
4715    ///
4716    /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
4717    /// capability transition treats a file's permitted set as all-ones when the process's real
4718    /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
4719    /// would be undone by the very next `execve`; emptying the bounding set is what makes the
4720    /// post-exec permitted (and hence effective) set empty, which is what actually takes
4721    /// `CAP_MKNOD` away from the shell.
4722    ///
4723    /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
4724    /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
4725    /// — and strictly narrows what the subprocess can reach.
4726    fn drop_all_capabilities() -> io::Result<()> {
4727        drop_capability_bounding_set()?;
4728        clear_capability_sets()?;
4729        set_no_new_privs()
4730    }
4731
4732    /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
4733    ///
4734    ///   - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
4735    ///   - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
4736    ///     process that never held the capability being dropped either. Hard-failing here would
4737    ///     break every non-root `mur run`.
4738    ///
4739    /// Any other errno is unexpected and propagates, aborting the exec — this module's
4740    /// fail-closed invariant: no silently-unenforced spawn.
4741    #[allow(unsafe_code)]
4742    fn drop_capability_bounding_set() -> io::Result<()> {
4743        let zero: libc::c_ulong = 0;
4744
4745        for cap in 0..=kernel_last_cap() {
4746            let cap_arg = libc::c_ulong::from(cap);
4747            // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
4748            // ignored arguments — no pointers and no borrowed memory are involved.
4749            let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
4750            if rc == 0 {
4751                continue;
4752            }
4753
4754            let error = io::Error::last_os_error();
4755            match error.raw_os_error() {
4756                Some(libc::EINVAL) | Some(libc::EPERM) => continue,
4757                _ => return Err(error),
4758            }
4759        }
4760
4761        Ok(())
4762    }
4763
4764    /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
4765    /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
4766    /// everything. Any error is therefore unexpected and fails closed.
4767    #[allow(unsafe_code)]
4768    fn clear_capability_sets() -> io::Result<()> {
4769        let header = CapUserHeader {
4770            version: LINUX_CAPABILITY_VERSION_3,
4771            // 0 means "the calling thread" — the only pid `capset` accepts for a capability
4772            // change since Linux 2.6.25.
4773            pid: 0,
4774        };
4775        let data = [CapUserData {
4776            effective: 0,
4777            permitted: 0,
4778            inheritable: 0,
4779        }; 2];
4780
4781        // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
4782        // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
4783        // expect. The kernel only reads through them.
4784        let rc = unsafe {
4785            libc::syscall(
4786                libc::SYS_capset,
4787                &header as *const CapUserHeader,
4788                data.as_ptr(),
4789            )
4790        };
4791        if rc != 0 {
4792            return Err(io::Error::last_os_error());
4793        }
4794
4795        Ok(())
4796    }
4797
4798    /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
4799    /// process's privilege and is never refused for a well-formed call, so any error is
4800    /// unexpected and fails closed. Same call convention and fail-closed style as
4801    /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
4802    /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
4803    /// whole-process hardening.
4804    #[allow(unsafe_code)]
4805    fn set_no_new_privs() -> io::Result<()> {
4806        #[cfg(test)]
4807        if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
4808            return Err(io::Error::other(
4809                "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
4810            ));
4811        }
4812        // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
4813        // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
4814        // upper 32 bits would be unspecified in a variadic call on aarch64.
4815        let enable: libc::c_ulong = 1;
4816        let zero: libc::c_ulong = 0;
4817
4818        // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
4819        // and no borrowed memory are involved.
4820        let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
4821        if rc != 0 {
4822            return Err(io::Error::last_os_error());
4823        }
4824
4825        Ok(())
4826    }
4827
4828    /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
4829    /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
4830    /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
4831    /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
4832    /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
4833    #[allow(unsafe_code)]
4834    fn kernel_last_cap() -> u32 {
4835        const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
4836
4837        // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
4838        // only reads through the pointer.
4839        let fd = unsafe {
4840            libc::open(
4841                PATH.as_ptr() as *const libc::c_char,
4842                libc::O_RDONLY | libc::O_CLOEXEC,
4843            )
4844        };
4845        if fd < 0 {
4846            return CAP_LAST_CAP_FALLBACK;
4847        }
4848
4849        let mut buf = [0u8; 16];
4850        // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
4851        // is passed, so `read` cannot write past it.
4852        let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
4853        // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
4854        unsafe { libc::close(fd) };
4855
4856        if read <= 0 {
4857            return CAP_LAST_CAP_FALLBACK;
4858        }
4859
4860        let parsed = std::str::from_utf8(&buf[..read as usize])
4861            .ok()
4862            .and_then(|text| text.trim().parse::<u32>().ok());
4863
4864        match parsed {
4865            Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
4866            _ => CAP_LAST_CAP_FALLBACK,
4867        }
4868    }
4869
4870    /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
4871    /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
4872    /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
4873    /// does support Landlock, so this is a real combination, not a hypothetical one).
4874    ///
4875    /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
4876    /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
4877    /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
4878    const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
4879        ("landlock_create_ruleset", 444),
4880        ("landlock_add_rule", 445),
4881        ("landlock_restrict_self", 446),
4882    ];
4883
4884    /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
4885    /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
4886    /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
4887    /// held against and the headroom between them are recorded in
4888    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4889    ///
4890    /// **This filter raises no notifications and needs no supervisor.** Network enforcement lives
4891    /// in [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
4892    /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
4893    /// `Notify` rule may come back.
4894    ///
4895    /// One mechanism plus a default:
4896    ///
4897    ///   - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
4898    ///     a plain integer argument, so the comparison compiles straight into the loaded BPF
4899    ///     program and is evaluated in-kernel at syscall time: no notification is raised and no
4900    ///     memory of another task is read. That is what makes it structurally immune to the
4901    ///     pointer-read TOCTOU class of problem in the first place.
4902    ///   - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
4903    ///     neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
4904    ///     argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
4905    ///     `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
4906    ///     unreachable — they are simply absent, not argument-matched.
4907    fn build_seccomp_filter(
4908        unix_sockets_allowed: bool,
4909    ) -> io::Result<libseccomp::ScmpFilterContext> {
4910        // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
4911        // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
4912        // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
4913        // call's arguments and refused it", `EPERM` means "this syscall is not part of the
4914        // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
4915        // unmediated-surface problem from a policy decision.
4916        let mut filter =
4917            libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4918                .map_err(to_io_err)?;
4919
4920        // Ask the kernel to record every action this filter takes that is not `Allow` in the
4921        // audit trail, so a denial is attributable after the fact to a syscall number, pid and
4922        // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
4923        // has no channel for anything richer — so this is the only way an operator debugging a
4924        // workload that dies on an unexpected denial gets a syscall name to look at.
4925        //
4926        // Best-effort on purpose, and the one place in this function where an error is swallowed:
4927        // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
4928        // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
4929        // not an enforcement one — the default-deny action and every rule below are unaffected —
4930        // and turning it into a hard failure would take a host with an old libseccomp from
4931        // "denials are logged less legibly" to "no capsule can spawn a shell at all".
4932        //
4933        // Setting the attribute is necessary but not sufficient: the kernel only logs an action
4934        // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
4935        // configuration this process does not control.
4936        // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
4937        // these denials are verified by.
4938        let _ = filter.set_ctl_log(true);
4939
4940        // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
4941        // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
4942        // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
4943        // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
4944        // sees one consistent errno across both argument-level mechanisms.
4945        //
4946        // Untouched by the network-namespace work, and deliberately so: a network namespace does
4947        // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
4948        // still the only thing standing between a capsule and `/var/run/docker.sock`.
4949        //
4950        // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
4951        // adapt the rule to the architectures in the filter — on an arch where `socket` is
4952        // reached through `socketcall(2)` rather than as its own syscall, the exact form would
4953        // fail the whole install instead. Fail-closed is preserved either way, since any error
4954        // here propagates out of `pre_exec` and aborts the spawn.
4955        //
4956        // One rule per domain rather than one rule with three comparators: comparators within a
4957        // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
4958        // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
4959        let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
4960        for domain in super::denied_socket_domains(unix_sockets_allowed) {
4961            filter
4962                .add_rule_conditional(
4963                    libseccomp::ScmpAction::Errno(libc::EACCES),
4964                    socket_syscall,
4965                    &[libseccomp::ScmpArgCompare::new(
4966                        0,
4967                        libseccomp::ScmpCompareOp::Equal,
4968                        domain as u64,
4969                    )],
4970                )
4971                .map_err(to_io_err)?;
4972        }
4973
4974        // The permitted domains need positive rules of their own now that the filter's default is
4975        // a deny — without them `socket()` would be refused for every family, IP included.
4976        //
4977        // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
4978        // libseccomp resolves an unconditional rule for a syscall by *discarding* every
4979        // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
4980        // but the new filter has no chains so the existing chains must be cleared"), so a broad
4981        // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
4982        // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
4983        // default profile carries the same warning against combining a broad `socket` allow with
4984        // explicit errno rules, and works around it the same way — with argument-matched rules
4985        // only. The two domain sets are disjoint by construction (a test pins that), so no
4986        // `socket()` call can match both an allow and a deny rule.
4987        for domain in super::allowed_socket_domains(unix_sockets_allowed) {
4988            filter
4989                .add_rule_conditional(
4990                    libseccomp::ScmpAction::Allow,
4991                    socket_syscall,
4992                    &[libseccomp::ScmpArgCompare::new(
4993                        0,
4994                        libseccomp::ScmpCompareOp::Equal,
4995                        domain as u64,
4996                    )],
4997                )
4998                .map_err(to_io_err)?;
4999        }
5000
5001        // The allowlist proper. Two failure modes are both handled by skipping the name:
5002        //
5003        //   - `from_name` fails when this host's libseccomp does not know the syscall (a name
5004        //     newer than the installed library);
5005        //   - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
5006        //     not exist on this architecture — every legacy x86_64 spelling in the array
5007        //     (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
5008        //
5009        // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
5010        // so the filter can only ever come out *narrower* than intended, never wider. Propagating
5011        // instead would abort every single spawn on the first arch-specific name, which is why
5012        // this is the deliberate exception to the module's otherwise-strict error handling.
5013        for name in super::SECCOMP_SYSCALL_ALLOWLIST {
5014            if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5015                let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
5016            }
5017        }
5018
5019        // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
5020        // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
5021        // deny would refuse the child's own filesystem scoping and abort every spawn on that
5022        // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
5023        // that installs it, never widen it, and Docker's default profile allows them too.
5024        //
5025        // Resolved by name where possible, with the syscall number as a fallback, because these
5026        // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
5027        // policy above would silently turn an older libseccomp into "no capsule can run a shell
5028        // on a Landlock-capable host". The numbers are the same on every architecture Linux has
5029        // added Landlock to (they were assigned after the syscall-number unification), so the
5030        // fallback is not architecture-specific.
5031        for (name, fallback_nr) in LANDLOCK_SYSCALLS {
5032            let syscall = libseccomp::ScmpSyscall::from_name(name)
5033                .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
5034            filter
5035                .add_rule(libseccomp::ScmpAction::Allow, syscall)
5036                .map_err(to_io_err)?;
5037        }
5038
5039        Ok(filter)
5040    }
5041
5042    /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
5043    /// the filter stays in force for the rest of this task's life and is inherited across the
5044    /// `execve` that follows, which is why nothing outside a forked child ever calls this.
5045    fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
5046        // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
5047        // on Drop) only frees the userspace filter-building context, while the filter itself is
5048        // already installed in the kernel and is inherited across the `execve` that follows.
5049        build_seccomp_filter(unix_sockets_allowed)?
5050            .load()
5051            .map_err(to_io_err)
5052    }
5053
5054    fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
5055        io::Error::other(error.to_string())
5056    }
5057
5058    /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
5059    /// instruction. Dividing an export's length by it is what turns bytes into an instruction
5060    /// count.
5061    #[cfg(test)]
5062    const SOCK_FILTER_BYTES: usize = 8;
5063
5064    /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
5065    /// returns what it wrote.
5066    ///
5067    /// Both exporters take a descriptor and nothing else, so the buffer has to come from
5068    /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
5069    /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
5070    /// crate and so is unavailable to anything outside a test build.
5071    #[cfg(test)]
5072    fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
5073    where
5074        F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
5075    {
5076        use std::io::{Read, Seek, SeekFrom};
5077        use std::os::fd::FromRawFd;
5078
5079        // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
5080        // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
5081        // the descriptor lives and dies inside this function.
5082        let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
5083        if raw < 0 {
5084            return Err(io::Error::last_os_error());
5085        }
5086        // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
5087        // is exactly the precondition for handing it to `OwnedFd`.
5088        let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
5089
5090        export(&memfd).map_err(to_io_err)?;
5091
5092        let mut file = std::fs::File::from(memfd);
5093        file.seek(SeekFrom::Start(0))?;
5094        let mut exported = Vec::new();
5095        file.read_to_end(&mut exported)?;
5096        Ok(exported)
5097    }
5098
5099    /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
5100    /// this host, for the given `unix_sockets_allowed`.
5101    ///
5102    /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
5103    /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
5104    /// divided by that record size. The number depends on the host — how many
5105    /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
5106    /// which is why it is measured here rather than written down.
5107    ///
5108    /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
5109    /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
5110    #[cfg(test)]
5111    fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
5112        let filter = build_seccomp_filter(unix_sockets_allowed)?;
5113        let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5114        Ok(exported.len() / SOCK_FILTER_BYTES)
5115    }
5116
5117    /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
5118    /// one block per syscall carrying a rule, each block naming the argument comparisons and the
5119    /// action. It is the only view of a built context that shows the rule *shape* rather than the
5120    /// compiled program's size, which is what a test pinning the rule sequence needs.
5121    #[cfg(test)]
5122    fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
5123        let filter = build_seccomp_filter(unix_sockets_allowed)?;
5124        let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
5125        String::from_utf8(exported).map_err(to_io_err)
5126    }
5127
5128    /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
5129    ///
5130    ///   - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
5131    ///     FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
5132    ///     `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
5133    ///     [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
5134    ///     is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
5135    ///     the path it resolved itself, so nothing the capsule writes into its workdir can run;
5136    ///   - the session's temporary folder gets that **same** right-set from the same local
5137    ///     binding, whenever the enforcement holds one. The composed root and the mount-namespace
5138    ///     mapping both bind it at `/tmp` read-write, but Landlock matches an access to `/tmp/x`
5139    ///     against the bind's own root inode rather than the workdir's, so without this rule the
5140    ///     mount is writable and every access to it is nevertheless `EACCES` — bash heredocs,
5141    ///     `mktemp`, a compiler's intermediate objects and a package manager's build scratch all
5142    ///     fail. Identical rights are the honest ones: it is counted by the same
5143    ///     `workdir_max_bytes` guard, so it must be neither more nor less runnable than the
5144    ///     workdir itself;
5145    ///   - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
5146    ///     shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
5147    ///     `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
5148    ///     [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
5149    ///     grant — never write — with two bits added on top.
5150    ///     Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
5151    ///     exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
5152    ///     file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
5153    ///     whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
5154    ///     — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
5155    ///     (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
5156    ///     `true` everywhere except the two tier-issued sealed grants, which are deliberately
5157    ///     readable (and, on a directory, enumerable) but not runnable;
5158    ///   - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
5159    ///     and `Truncate` if its `writable` bit is set. That bit is set for exactly one path —
5160    ///     `/dev/null` — which is therefore the *only* writable path outside the workdir in the
5161    ///     whole sandbox. This list is a compile-time constant, not manifest-derived: no capability
5162    ///     declaration widens or narrows it. Every device path it does not name stays denied by the
5163    ///     ordinary no-matching-rule path below.
5164    ///
5165    /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
5166    /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
5167    /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
5168    /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
5169    /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
5170    /// so a write to any other outside path — including one of these read+execute grant paths — is
5171    /// still denied.
5172    ///
5173    /// The ruleset is built at `fds.abi` — the kernel's ABI capped at
5174    /// [`super::LANDLOCK_TARGET_ABI`], decided in the parent — or at ABI v1 when that is `None`.
5175    /// `handle_access` declares the **full** `from_all` set of that ABI, under `CompatLevel::HardRequirement`, and every rule's
5176    /// rights are masked to it first: a right the ABI does not know would otherwise fail
5177    /// `add_rule`, and masking it out is what a kernel of that ABI would enforce anyway. Built at
5178    /// v1, the ruleset is bit for bit the one a 5.13–5.18 kernel gets.
5179    ///
5180    /// `handle_access` must declare the union of every access bit any rule uses, and it is the
5181    /// full set deliberately: a bit declared there but granted by no rule is denied for
5182    /// the whole domain, which is exactly how the rights missing from
5183    /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
5184    /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
5185    /// path, on a host where one of the three is missing — that
5186    /// fails to *open* is skipped
5187    /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
5188    /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
5189    /// preserving the module's fail-closed invariant.
5190    ///
5191    /// `proc_fd` is the root of the session's private `/proc`, mounted and opened in this child by
5192    /// `crate::proc_view::mount_private_proc`: `ReadFile | ReadDir` beneath it, never `Execute`
5193    /// and never a write right. `None` when the view is the host's, which then keeps no rule and
5194    /// every open beneath `/proc` is refused.
5195    fn apply_landlock_scope(
5196        fds: &LandlockChildFds,
5197        proc_fd: Option<&OwnedFd>,
5198    ) -> Result<(), String> {
5199        #[cfg(test)]
5200        if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
5201            return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
5202        }
5203
5204        use landlock::{
5205            Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
5206            RulesetCreatedAttr, ABI,
5207        };
5208
5209        let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
5210        let handled = AccessFs::from_all(abi);
5211        // Enough for the loader to open, read, and map-execute a binary or shared library, and to
5212        // open a file inside a granted directory by exact name — but no write access, and (without
5213        // `ReadDir`) no enumeration of the directory's own entries.
5214        let read_execute = AccessFs::Execute | AccessFs::ReadFile;
5215        // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
5216        let read_execute_list = read_execute | AccessFs::ReadDir;
5217        // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
5218        // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
5219        // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
5220        let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
5221        // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
5222        // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
5223        // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
5224        let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5225        // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
5226        // not a directory) — only the data bits, with `WriteFile` reserved for the writable
5227        // devices. `Truncate` rides with `WriteFile` because a shell's `>/dev/null` opens with
5228        // `O_TRUNC`, and handling `Truncate` from ABI v3 would otherwise refuse it.
5229        let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5230        let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
5231
5232        // The one writable right-set in this function, resolved once and used by both rules that
5233        // carry it. The session's temporary folder is counted by the workdir's size guard and is
5234        // the capsule's other scratch space, so it gets the same rights, `Execute` axis included:
5235        // `workdir_exec: false` must no more permit running a binary from `/tmp` than from the
5236        // workdir. Sharing the binding is what keeps that true; two separate calls could drift
5237        // apart.
5238        let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
5239
5240        // Every fd here was already opened in the parent (before fork). This function performs no
5241        // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
5242        // so a failure here can only be the kernel call itself, not an unrelated path resolution.
5243        let mut ruleset = Ruleset::default()
5244            .set_compatibility(CompatLevel::HardRequirement)
5245            .handle_access(handled)
5246            .map_err(|error| format!("landlock: handle_access failed: {error}"))?
5247            .create()
5248            .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
5249            .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
5250            .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
5251
5252        // The session's private `/proc`: read and list, nothing else. On `KernelSealed` the
5253        // composed root binds this same mount, so the rule reaches it there by the same inode.
5254        if let Some(proc_fd) = proc_fd {
5255            ruleset = ruleset
5256                .add_rule(PathBeneath::new(proc_fd.as_fd(), read_list & handled))
5257                .map_err(|error| {
5258                    format!("landlock: add_rule for the session's private /proc failed: {error}")
5259                })?;
5260        }
5261
5262        // The session's temporary folder, whenever the enforcement holds one. A rule on the
5263        // workdir does not reach a bind mounted over `/tmp`; see [`LandlockChildFds::temp_dir_fd`].
5264        if let Some(temp_dir_fd) = &fds.temp_dir_fd {
5265            ruleset = ruleset
5266                .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
5267                .map_err(|error| {
5268                    format!("landlock: add_rule for the session's temporary folder failed: {error}")
5269                })?;
5270        }
5271
5272        // The session's home folder, on exactly the temporary folder's terms: the workdir's
5273        // rights beneath the records directory's read-only rule.
5274        if let Some(home_dir_fd) = &fds.home_dir_fd {
5275            ruleset = ruleset
5276                .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
5277                .map_err(|error| {
5278                    format!("landlock: add_rule for the session's home folder failed: {error}")
5279                })?;
5280        }
5281
5282        // Every host path the operator's global config lends this session. A runtime folder
5283        // beneath one keeps its own rule below; on `KernelSealed` its bind is what keeps it
5284        // read-only, since Landlock rights accumulate down the tree.
5285        for (host_path_fd, read_write) in &fds.host_path_fds {
5286            ruleset = ruleset
5287                .add_rule(PathBeneath::new(
5288                    host_path_fd.as_fd(),
5289                    host_path_access_rights(*read_write) & handled,
5290                ))
5291                .map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
5292        }
5293
5294        // The runtime directory: readable and listable, never writable or executable.
5295        if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
5296            ruleset = ruleset
5297                .add_rule(PathBeneath::new(
5298                    runtime_dir_fd.as_fd(),
5299                    read_list & handled,
5300                ))
5301                .map_err(|error| {
5302                    format!("landlock: add_rule for the runtime directory failed: {error}")
5303                })?;
5304        }
5305
5306        // The records directory, on the same terms: the runtime appends to the trace from
5307        // outside the domain, and no command inside it can.
5308        if let Some(records_dir_fd) = &fds.records_dir_fd {
5309            ruleset = ruleset
5310                .add_rule(PathBeneath::new(
5311                    records_dir_fd.as_fd(),
5312                    read_list & handled,
5313                ))
5314                .map_err(|error| {
5315                    format!("landlock: add_rule for the records directory failed: {error}")
5316                })?;
5317        }
5318
5319        // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
5320        // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
5321        // the grant loop's rules for those two paths are taken on the host's files, not on these.
5322        for identity_fd in &fds.sealed_identity_fds {
5323            ruleset = ruleset
5324                .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5325                .map_err(|error| {
5326                    format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5327                })?;
5328        }
5329
5330        for grant in &fds.grants {
5331            // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
5332            // so every fd reaching here is valid. A rule that still fails to add is a genuine
5333            // ruleset-construction failure and propagates as `Err` (fail-closed).
5334            let access = match (grant.executable, grant.list_dir) {
5335                (true, true) => read_execute_list,
5336                (true, false) => read_execute,
5337                (false, true) => read_list,
5338                (false, false) => read_only,
5339            };
5340            ruleset = ruleset
5341                .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5342                .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5343        }
5344
5345        for device in &fds.devices {
5346            // Same convention as the grant loop above: a device that failed to open in the parent
5347            // was already dropped, so a failure here is a genuine ruleset-construction failure and
5348            // propagates as `Err` (fail-closed).
5349            let access = if device.writable {
5350                device_read_write
5351            } else {
5352                device_read
5353            };
5354            ruleset = ruleset
5355                .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5356                .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5357        }
5358
5359        ruleset
5360            .restrict_self()
5361            .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
5362
5363        Ok(())
5364    }
5365
5366    /// What [`open_landlock_fds`] opens, or carries through, for one launch.
5367    pub(super) struct LandlockFdRequest<'a> {
5368        /// The workdir the capsule's commands run in. Fail-closed: a workdir that cannot be
5369        /// opened is an `Err` naming the path.
5370        pub(super) accessible_workdir: &'a Path,
5371        /// The manifest-derived grants. A grant path that fails to open is dropped
5372        /// (shrink-not-fail).
5373        pub(super) landlock_grants: &'a [LandlockGrant],
5374        /// The tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on `KernelFull` and the
5375        /// composed root's own OCI default set on `KernelSealed`, chosen by
5376        /// `super::landlock_device_grants`. Opened by the grants' rules: the list is a
5377        /// compile-time constant rather than manifest-derived, and a host where one entry cannot
5378        /// be opened — unusual, but a broken or minimal `/dev` is possible — loses that one device
5379        /// rather than failing the launch. A lost device, `/dev/null` included, narrows the
5380        /// capsule's scope and never widens it.
5381        pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
5382        /// Carried into [`LandlockChildFds::workdir_exec`].
5383        pub(super) workdir_exec: bool,
5384        /// Carried into [`LandlockChildFds::abi`].
5385        pub(super) abi: Option<u8>,
5386        /// What only `KernelSealed` opens: the synthetic `/etc` files. Fail-closed.
5387        pub(super) sealed: Option<&'a super::SealedSourceFds>,
5388        /// The session's temporary folder's descriptor on both Landlock tiers, which
5389        /// `prepare_enforcement` already took and shares with the sealed binds. Fail-closed — an
5390        /// `Err` naming the path, exactly like the workdir's own open, never the grant loops'
5391        /// shrink-not-fail `continue`.
5392        pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5393        /// The session's home folder's descriptor, on the same terms as [`Self::temp_dir_fd`].
5394        pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5395        /// The runtime directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
5396        pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5397        /// The records directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
5398        pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5399        /// Every host path's descriptor, from [`open_host_path`], and whether it is
5400        /// `read-write`. Carried into [`LandlockChildFds::host_path_fds`].
5401        pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
5402    }
5403
5404    impl<'a> LandlockFdRequest<'a> {
5405        /// `accessible_workdir` and nothing else: no grants, no devices, no sealed sources and
5406        /// no directory fds.
5407        #[cfg(test)]
5408        pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
5409            Self {
5410                accessible_workdir,
5411                landlock_grants: &[],
5412                device_grants: &[],
5413                workdir_exec: false,
5414                abi: None,
5415                sealed: None,
5416                temp_dir_fd: None,
5417                home_dir_fd: None,
5418                runtime_dir_fd: None,
5419                records_dir_fd: None,
5420                host_path_fds: Vec::new(),
5421            }
5422        }
5423    }
5424
5425    /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
5426    /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
5427    /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
5428    /// now fails here, synchronously, with a message naming the path — before any subprocess is
5429    /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
5430    /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
5431    ///
5432    /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
5433    /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
5434    /// only, never widening.
5435    ///
5436    /// The request's devices are opened by the same rules as its grants, and its directory fds
5437    /// move into the returned [`LandlockChildFds`]. See [`LandlockFdRequest`] for each field.
5438    pub(super) fn open_landlock_fds(
5439        request: LandlockFdRequest<'_>,
5440    ) -> Result<LandlockChildFds, String> {
5441        let LandlockFdRequest {
5442            accessible_workdir,
5443            landlock_grants,
5444            device_grants,
5445            workdir_exec,
5446            abi,
5447            sealed,
5448            temp_dir_fd,
5449            home_dir_fd,
5450            runtime_dir_fd,
5451            records_dir_fd,
5452            host_path_fds,
5453        } = request;
5454        let workdir_fd = open_o_path(accessible_workdir)
5455            .map(OwnedFd::from)
5456            .map_err(|error| {
5457                format!(
5458                    "sandbox: failed to open workdir {} for Landlock scoping: {error}",
5459                    accessible_workdir.display()
5460                )
5461            })?;
5462
5463        // Same tier gate, same fail-closed handling: the composed root binds these two files at
5464        // `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of those
5465        // names, whose inodes the capsule never sees. See `LandlockChildFds`. `sealed-etc/` is
5466        // written by the runtime alone.
5467        let mut sealed_identity_fds = Vec::new();
5468        for path in sealed
5469            .map(|sources| sources.identity_files.as_slice())
5470            .unwrap_or_default()
5471        {
5472            let file = open_o_path(path).map_err(|error| {
5473                format!(
5474                    "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
5475                     {error}",
5476                    path.display()
5477                )
5478            })?;
5479            sealed_identity_fds.push(OwnedFd::from(file));
5480        }
5481
5482        let mut grants = Vec::with_capacity(landlock_grants.len());
5483        for grant in landlock_grants {
5484            match open_o_path(&grant.path) {
5485                Ok(file) => grants.push(OpenLandlockGrant {
5486                    // `ReadDir` is meaningful only on a directory, and the kernel does not merely
5487                    // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
5488                    // directory-only right on a non-directory, and the rule loop in
5489                    // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
5490                    // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
5491                    // `interpreter_runtime` entry, or the static `directory` bit on a
5492                    // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
5493                    // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
5494                    // never add it.
5495                    list_dir: grant.list_dir
5496                        && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
5497                    fd: OwnedFd::from(file),
5498                    executable: grant.executable,
5499                }),
5500                Err(_) => continue,
5501            }
5502        }
5503
5504        let mut devices = Vec::with_capacity(device_grants.len());
5505        for device in device_grants {
5506            match open_o_path(Path::new(device.path)) {
5507                Ok(file) => devices.push(OpenDeviceGrant {
5508                    fd: OwnedFd::from(file),
5509                    writable: device.writable,
5510                }),
5511                Err(_) => continue,
5512            }
5513        }
5514
5515        Ok(LandlockChildFds {
5516            workdir_fd,
5517            workdir_exec,
5518            abi,
5519            temp_dir_fd,
5520            home_dir_fd,
5521            sealed_identity_fds,
5522            runtime_dir_fd,
5523            records_dir_fd,
5524            host_path_fds,
5525            grants,
5526            devices,
5527        })
5528    }
5529
5530    /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
5531    /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
5532    /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
5533    /// closure.
5534    ///
5535    /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
5536    /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
5537    /// not perform a second path lookup to get it.
5538    fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
5539        use std::os::unix::fs::OpenOptionsExt;
5540        std::fs::OpenOptions::new()
5541            .read(true)
5542            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
5543            .open(path)
5544    }
5545
5546    /// An `O_PATH` handle on a host path, its Landlock rule's fd and its sealed bind's identity.
5547    /// `O_NOFOLLOW | O_DIRECTORY` on the directory the entry resolved to at launch: a link planted
5548    /// at its name since, or anything but a directory, fails the spawn naming the path.
5549    pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
5550        use std::os::unix::fs::OpenOptionsExt;
5551        std::fs::OpenOptions::new()
5552            .read(true)
5553            .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
5554            .open(path)
5555            .map(OwnedFd::from)
5556            .map_err(|error| {
5557                format!(
5558                    "sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
5559                     {error}",
5560                    path.display()
5561                )
5562            })
5563    }
5564
5565    /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
5566    /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
5567    /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
5568    /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
5569    /// returns `Err`, so `execve` is still aborted).
5570    #[allow(unsafe_code)]
5571    pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
5572        let bytes = message.as_bytes();
5573        let len = bytes.len().min(MAX_DIAG_LEN);
5574        let mut written = 0;
5575        while written < len {
5576            // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
5577            // name a live sub-slice of `bytes` for the duration of this single `write` call.
5578            let n = unsafe {
5579                libc::write(
5580                    fd,
5581                    bytes[written..len].as_ptr() as *const libc::c_void,
5582                    len - written,
5583                )
5584            };
5585            if n < 0 {
5586                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5587                    continue;
5588                }
5589                break;
5590            }
5591            if n == 0 {
5592                break;
5593            }
5594            written += n as usize;
5595        }
5596    }
5597
5598    /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
5599    /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
5600    /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
5601    /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
5602    /// failure that never ran `pre_exec`).
5603    #[allow(unsafe_code)]
5604    pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
5605        let mut buf = Vec::new();
5606        let mut chunk = [0u8; 256];
5607        loop {
5608            // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
5609            // stack buffer of exactly the length passed.
5610            let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
5611            if n < 0 {
5612                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5613                    continue;
5614                }
5615                break;
5616            }
5617            if n == 0 {
5618                break;
5619            }
5620            buf.extend_from_slice(&chunk[..n as usize]);
5621            if buf.len() >= MAX_DIAG_LEN {
5622                break;
5623            }
5624        }
5625        if buf.is_empty() {
5626            None
5627        } else {
5628            Some(String::from_utf8_lossy(&buf).into_owned())
5629        }
5630    }
5631
5632    /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
5633    /// against — all four numbers produced by running code on the host under test, never quoted.
5634    ///
5635    /// The written record these tests feed is
5636    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
5637    ///
5638    /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
5639    /// irreversible for the life of the task, so an in-process load would silently apply to the
5640    /// rest of the test binary and to everything it spawns. Every load happens in a forked child
5641    /// that does nothing else and then `_exit`s.
5642    #[cfg(test)]
5643    #[allow(clippy::print_stdout, clippy::print_stderr)]
5644    mod seccomp_budget {
5645        use std::io;
5646        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
5647
5648        // `seccomp_filter_instruction_count` is absent from this list because the test that
5649        // reports it carries the same name; it is called as `super::` throughout.
5650        use super::{
5651            build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
5652            LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
5653        };
5654        use crate::sandbox::{
5655            allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
5656            EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
5657            SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
5658        };
5659
5660        /// The kernel charges each filter already attached to a task four instructions on top of
5661        /// its own length when it decides whether the next attach fits under
5662        /// [`SECCOMP_MAX_INSNS_PER_PATH`].
5663        ///
5664        /// It is only part of the arithmetic. The length the kernel adds up is that of the
5665        /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
5666        /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
5667        /// out slightly longer. So a load count predicted from the exported classic length is an
5668        /// upper bound on the load count the kernel actually allows, never an equality, which is
5669        /// why the cumulative probe brackets it rather than pinning it.
5670        const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
5671
5672        /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
5673        /// Every real errno is a small positive number, and 0 is reserved for a load that
5674        /// succeeded, so this cannot collide with either.
5675        const PROBE_ERRNO_UNKNOWN: i32 = 254;
5676
5677        /// A filter built only to be measured or refused: default `Allow`, and `rules`
5678        /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
5679        ///
5680        /// Default `Allow` rather than the real filter's default deny, for two reasons that both
5681        /// matter. A child has to survive its own load long enough to `_exit` and, in the
5682        /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
5683        /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
5684        /// equals the default action, so the rules have to carry an action the default does not:
5685        /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
5686        /// every rule in the program.
5687        ///
5688        /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
5689        /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
5690        /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
5691        fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
5692            let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
5693                .map_err(to_io_err)?;
5694            filter.set_api_sysrawrc(true).map_err(to_io_err)?;
5695
5696            let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
5697            for value in 0..rules {
5698                filter
5699                    .add_rule_conditional(
5700                        libseccomp::ScmpAction::Errno(libc::EPERM),
5701                        syscall,
5702                        &[libseccomp::ScmpArgCompare::new(
5703                            0,
5704                            libseccomp::ScmpCompareOp::Equal,
5705                            value as u64,
5706                        )],
5707                    )
5708                    .map_err(to_io_err)?;
5709            }
5710
5711            let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5712            Ok((filter, exported.len() / SOCK_FILTER_BYTES))
5713        }
5714
5715        /// The smallest probe-filter rule count whose compiled program is longer than
5716        /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
5717        /// monotonically with the rule count, so both halves are sound and the whole search
5718        /// costs a couple of dozen exports rather than thousands.
5719        fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
5720            let mut under = 1;
5721            let mut over = 8;
5722            while probe_filter(over)?.1 <= instructions {
5723                under = over;
5724                over *= 2;
5725                assert!(
5726                    over <= 1 << 20,
5727                    "no probe filter of up to {over} rules exceeded {instructions} instructions — \
5728                     the probe is not growing the program the way this search assumes"
5729                );
5730            }
5731            while over - under > 1 {
5732                let mid = under + (over - under) / 2;
5733                if probe_filter(mid)?.1 <= instructions {
5734                    under = mid;
5735                } else {
5736                    over = mid;
5737                }
5738            }
5739            Ok(over)
5740        }
5741
5742        /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
5743        /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
5744        /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
5745        /// only ever sees a plain `libc` value.
5746        fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
5747            use libseccomp::error::SeccompErrno;
5748
5749            if let Some(raw) = error.sysrawrc() {
5750                return -raw;
5751            }
5752            match error.errno() {
5753                Some(SeccompErrno::EACCES) => libc::EACCES,
5754                Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
5755                Some(SeccompErrno::EINVAL) => libc::EINVAL,
5756                Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
5757                _ => PROBE_ERRNO_UNKNOWN,
5758            }
5759        }
5760
5761        /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
5762        /// that took, `Err(errno)` for one the kernel refused.
5763        ///
5764        /// The filter is built by the caller, before the fork, so the child's whole post-fork
5765        /// window is `load()` and `_exit`.
5766        fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
5767            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
5768            // child confines itself to work that does not depend on another thread's state. This
5769            // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
5770            // the BPF program on every load and does not cache the one `export_bpf` produced —
5771            // so this probe is not async-signal-safe in the strict sense; it is bounded to a
5772            // child that touches nothing else and dies immediately.
5773            let pid = unsafe { libc::fork() };
5774            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5775
5776            if pid == 0 {
5777                let code = match filter.load() {
5778                    Ok(()) => 0,
5779                    Err(error) => refusal_errno(&error),
5780                };
5781                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
5782                // which is what keeps libtest's state in the parent untouched.
5783                unsafe { libc::_exit(code) }
5784            }
5785
5786            let mut status: libc::c_int = 0;
5787            // SAFETY: `pid` is the child just forked; `status` is a live local.
5788            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5789            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5790            assert!(
5791                libc::WIFEXITED(status),
5792                "the load probe child did not exit normally (status {status})"
5793            );
5794            match libc::WEXITSTATUS(status) {
5795                0 => Ok(()),
5796                errno => Err(errno),
5797            }
5798        }
5799
5800        /// The number of distinct syscalls the allowlist actually contributes a rule for on this
5801        /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
5802        /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
5803        /// is an independent measurement rather than a restatement of the filter under test.
5804        fn resolvable_allowlist_syscalls() -> io::Result<usize> {
5805            use std::collections::HashSet;
5806
5807            let mut scratch =
5808                libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5809                    .map_err(to_io_err)?;
5810            let mut accepted = HashSet::new();
5811            for name in SECCOMP_SYSCALL_ALLOWLIST {
5812                if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5813                    if scratch
5814                        .add_rule(libseccomp::ScmpAction::Allow, syscall)
5815                        .is_ok()
5816                    {
5817                        accepted.insert(i32::from(syscall));
5818                    }
5819                }
5820            }
5821            Ok(accepted.len())
5822        }
5823
5824        /// The line both ceiling probes print instead of running, on a host where no seccomp
5825        /// filter can be loaded at all.
5826        fn skip_without_seccomp(what: &str) -> bool {
5827            let tier = detect_enforcement_tier();
5828            if tier == EnforcementTier::EnvironmentOnly {
5829                eprintln!(
5830                    "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
5831                     can load a seccomp filter at all, which is every tier above \
5832                     EnforcementTier::EnvironmentOnly; detected {tier:?}"
5833                );
5834                return true;
5835            }
5836            false
5837        }
5838
5839        /// What the real filter compiles to, against the budget this project holds itself to and
5840        /// the ceiling the kernel holds it to.
5841        ///
5842        /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
5843        /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
5844        /// cover the larger of the two.
5845        #[test]
5846        fn seccomp_filter_instruction_count() {
5847            for unix_sockets_allowed in [false, true] {
5848                let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
5849                    .expect("export the built seccomp filter");
5850
5851                eprintln!(
5852                    "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
5853                     -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
5854                     per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
5855                     cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
5856                    BPF_MAXINSNS_CEILING as f64 / count as f64,
5857                    SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
5858                );
5859
5860                assert!(
5861                    count > 0,
5862                    "the exported filter is empty, so nothing was measured — \
5863                     `export_bpf` wrote no `struct sock_filter` records at all"
5864                );
5865                assert!(
5866                    count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
5867                    "the child's seccomp filter compiles to {count} instructions with \
5868                     unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
5869                     {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
5870                     {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
5871                     still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
5872                     deliberately — updating \
5873                     docs/content/reference/seccomp-filter-instruction-budget.md with the new \
5874                     numbers — rather than letting the filter grow into a spawn failure"
5875                );
5876            }
5877        }
5878
5879        /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
5880        /// pseudo-filter-code export so a change to the shape is a test failure rather than a
5881        /// silent change of policy.
5882        ///
5883        /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
5884        /// anywhere in this function would make libseccomp discard every argument-conditional
5885        /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
5886        /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
5887        #[test]
5888        fn seccomp_filter_rule_shape() {
5889            let allowlist_syscalls =
5890                resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
5891            assert!(
5892                allowlist_syscalls > 0,
5893                "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
5894                 for the wrong reason"
5895            );
5896
5897            for unix_sockets_allowed in [false, true] {
5898                let filter =
5899                    build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
5900                assert_eq!(
5901                    filter.get_act_default().expect("read the default action"),
5902                    libseccomp::ScmpAction::Errno(libc::EPERM),
5903                    "the filter's default action is no longer a deny with EPERM, which is what \
5904                     makes every syscall outside the allowlist unreachable"
5905                );
5906
5907                let pfc =
5908                    seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
5909                let denied = denied_socket_domains(unix_sockets_allowed);
5910                let allowed = allowed_socket_domains(unix_sockets_allowed);
5911
5912                // One block per syscall carrying at least one rule: `socket`, every allowlist
5913                // name libseccomp accepted, and the three Landlock syscalls.
5914                let syscall_blocks = pfc.matches("# filter for syscall ").count();
5915                assert_eq!(
5916                    syscall_blocks,
5917                    1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5918                    "the built filter carries rules for {syscall_blocks} syscalls, not the \
5919                     socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
5920                     syscalls this code adds",
5921                    LANDLOCK_SYSCALLS.len()
5922                );
5923
5924                let deny_actions = pfc
5925                    .matches(&format!("action ERRNO({});", libc::EACCES))
5926                    .count();
5927                assert_eq!(
5928                    deny_actions,
5929                    denied.len(),
5930                    "the built filter carries {deny_actions} EACCES rules, not one per denied \
5931                     socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
5932                     exactly like this, and would have discarded every domain denial with it"
5933                );
5934
5935                // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
5936                // contributes one per allowed domain.
5937                let allow_actions = pfc.matches("action ALLOW;").count();
5938                assert_eq!(
5939                    allow_actions,
5940                    allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5941                    "the built filter carries {allow_actions} Allow rules, not one per allowed \
5942                     socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
5943                     Landlock syscall"
5944                );
5945
5946                for (name, _) in LANDLOCK_SYSCALLS {
5947                    assert!(
5948                        pfc.contains(&format!("\"{name}\"")),
5949                        "{name} has no rule in the built filter, so `apply_landlock_scope` would \
5950                         be refused by the filter installed just before it"
5951                    );
5952                }
5953
5954                eprintln!(
5955                    "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
5956                     default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
5957                     {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
5958                    denied.len(),
5959                    allowed.len(),
5960                    LANDLOCK_SYSCALLS.len(),
5961                );
5962            }
5963        }
5964
5965        /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
5966        /// two filters that straddle it and observing that the kernel takes one and refuses the
5967        /// other with `EINVAL`.
5968        ///
5969        /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
5970        /// ceiling, whatever the filter's length.
5971        #[test]
5972        fn seccomp_per_filter_ceiling() {
5973            if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
5974                return;
5975            }
5976
5977            let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
5978                .expect("bracket the per-filter ceiling");
5979            let (under_filter, under_count) =
5980                probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
5981            let (over_filter, over_count) =
5982                probe_filter(over_rules).expect("build the over-ceiling probe filter");
5983
5984            assert!(
5985                under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
5986                "the two probe filters ({under_count} and {over_count} instructions) do not \
5987                 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
5988                 nothing about where the ceiling is"
5989            );
5990
5991            let under_outcome = load_in_child(&under_filter);
5992            let over_outcome = load_in_child(&over_filter);
5993
5994            eprintln!(
5995                "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
5996                 instructions; smallest that did not = {over_count} instructions, refused with \
5997                 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
5998                over_outcome.err().unwrap_or(0),
5999                libc::EINVAL,
6000            );
6001
6002            assert_eq!(
6003                under_outcome,
6004                Ok(()),
6005                "a {under_count}-instruction filter was refused, below the \
6006                 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
6007            );
6008            assert_eq!(
6009                over_outcome,
6010                Err(libc::EINVAL),
6011                "a {over_count}-instruction filter was not refused with EINVAL, so \
6012                 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
6013                 per-filter limit"
6014            );
6015        }
6016
6017        /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
6018        /// task carries, by stacking filters in a forked child until the kernel refuses.
6019        ///
6020        /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
6021        /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
6022        /// filter of a few hundred instructions, and that had not yet reached `load()` when it
6023        /// failed, did not come from the kernel's seccomp code.
6024        #[test]
6025        fn seccomp_cumulative_ceiling() {
6026            if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
6027                return;
6028            }
6029
6030            // A probe of roughly the shipped filter's size, so the number of stacked loads reads
6031            // directly as "how many filters like ours one task could carry".
6032            let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
6033            let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
6034            // Enough headroom past the ceiling that a kernel which never refuses is caught by the
6035            // cap rather than by the test hanging.
6036            let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
6037
6038            let mut fds = [0 as libc::c_int; 2];
6039            // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
6040            let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
6041            assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
6042            // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
6043            let (read_fd, write_fd) =
6044                unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
6045
6046            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
6047            // child confines itself to work that does not depend on another thread's state. This
6048            // child calls `load()` in a loop, writes eight bytes and `_exit`s.
6049            let pid = unsafe { libc::fork() };
6050            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6051
6052            if pid == 0 {
6053                let mut loads: u32 = 0;
6054                let mut refusal: i32 = 0;
6055                while loads < max_loads as u32 {
6056                    match filter.load() {
6057                        Ok(()) => loads += 1,
6058                        Err(error) => {
6059                            refusal = refusal_errno(&error);
6060                            break;
6061                        }
6062                    }
6063                }
6064
6065                let mut report = [0u8; 8];
6066                report[..4].copy_from_slice(&loads.to_ne_bytes());
6067                report[4..].copy_from_slice(&refusal.to_ne_bytes());
6068                // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
6069                // local of exactly the length passed, and the descriptor is the pipe's write end.
6070                unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
6071                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
6072                unsafe { libc::_exit(0) }
6073            }
6074
6075            drop(write_fd);
6076            let mut report = [0u8; 8];
6077            std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
6078                .expect("read the cumulative probe child's report");
6079            let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
6080            let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
6081
6082            let mut status: libc::c_int = 0;
6083            // SAFETY: `pid` is the child just forked; `status` is a live local.
6084            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6085            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6086
6087            let stacked = loads * count;
6088            let cumulative_at_refusal = count + loads * per_filter_cost;
6089            let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
6090            let shipped = super::seccomp_filter_instruction_count(false)
6091                .expect("measure the shipped filter for comparison");
6092
6093            eprintln!(
6094                "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
6095                 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
6096                 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
6097                 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
6098                 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
6099                 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
6100                 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
6101                 instructions, so reaching this ceiling would take {} of them in a single task.",
6102                loads + 1,
6103                libc::ENOMEM,
6104                SECCOMP_MAX_INSNS_PER_PATH / shipped,
6105            );
6106
6107            assert!(
6108                loads >= 2,
6109                "the kernel refused the filter chain after only {loads} load(s), so this is not \
6110                 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
6111            );
6112            assert_eq!(
6113                refusal,
6114                libc::ENOMEM,
6115                "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
6116                 {refusal}, not ENOMEM ({}) — the rule-out in \
6117                 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
6118                 being what this ceiling returns",
6119                libc::ENOMEM
6120            );
6121            assert!(
6122                loads <= loads_if_counted_classic,
6123                "the kernel took {loads} loads of a {count}-instruction filter — more than the \
6124                 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
6125                 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
6126                 each one, so this kernel's ceiling is higher than the recorded value"
6127            );
6128            // Loose on purpose: the exported classic length under-counts what the kernel adds
6129            // up, so the bound below tolerates any plausible conversion expansion while still
6130            // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
6131            // say, BPF_MAXINSNS would fail here.
6132            assert!(
6133                loads * 2 > loads_if_counted_classic,
6134                "the kernel refused after {loads} loads of a {count}-instruction filter, far \
6135                 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
6136                 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
6137                 one recorded"
6138            );
6139            assert!(
6140                shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
6141                "the shipped filter ({shipped} instructions) is within a factor of two of the \
6142                 cumulative ceiling, which would make the rule-out this test supports untrue"
6143            );
6144        }
6145    }
6146}
6147
6148/// Builds a minimal but well-formed ELF64 image exercising exactly the fields
6149/// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
6150/// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
6151/// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
6152#[cfg(test)]
6153pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6154    let has_dynamic = !needed.is_empty() || runpath.is_some();
6155
6156    let mut phnum: u16 = 1; // PT_LOAD
6157    if interp.is_some() {
6158        phnum += 1;
6159    }
6160    if has_dynamic {
6161        phnum += 1;
6162    }
6163
6164    let ehsize = 64usize;
6165    let phentsize = 56usize;
6166    let phoff = ehsize;
6167    let body_start = phoff + phentsize * phnum as usize;
6168
6169    // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
6170    let mut strtab: Vec<u8> = vec![0];
6171    let mut needed_offsets = Vec::new();
6172    for name in needed {
6173        needed_offsets.push(strtab.len() as u64);
6174        strtab.extend_from_slice(name.as_bytes());
6175        strtab.push(0);
6176    }
6177    let runpath_offset = runpath.map(|r| {
6178        let off = strtab.len() as u64;
6179        strtab.extend_from_slice(r.as_bytes());
6180        strtab.push(0);
6181        off
6182    });
6183
6184    let mut body: Vec<u8> = Vec::new();
6185
6186    let interp_offset = body_start + body.len();
6187    if let Some(i) = interp {
6188        body.extend_from_slice(i.as_bytes());
6189        body.push(0);
6190    }
6191
6192    // Align the dynamic array to 8 bytes.
6193    while !(body_start + body.len()).is_multiple_of(8) {
6194        body.push(0);
6195    }
6196    let dyn_offset = body_start + body.len();
6197    let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
6198    let dyn_size = num_dyn * 16;
6199    let strtab_offset = dyn_offset + dyn_size;
6200
6201    if has_dynamic {
6202        let mut push_dyn = |tag: u64, val: u64| {
6203            body.extend_from_slice(&tag.to_le_bytes());
6204            body.extend_from_slice(&val.to_le_bytes());
6205        };
6206        for &o in &needed_offsets {
6207            push_dyn(1, o); // DT_NEEDED
6208        }
6209        if let Some(ro) = runpath_offset {
6210            push_dyn(29, ro); // DT_RUNPATH
6211        }
6212        push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
6213        push_dyn(10, strtab.len() as u64); // DT_STRSZ
6214        push_dyn(0, 0); // DT_NULL
6215        assert_eq!(body_start + body.len(), strtab_offset);
6216        body.extend_from_slice(&strtab);
6217    }
6218
6219    let total_len = body_start + body.len();
6220    let mut out = vec![0u8; total_len];
6221    out[0..4].copy_from_slice(b"\x7fELF");
6222    out[4] = 2; // ELFCLASS64
6223    out[5] = 1; // little-endian
6224    out[6] = 1; // EI_VERSION
6225    out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
6226    out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
6227    out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
6228    out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
6229    out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
6230    out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
6231
6232    let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
6233    phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
6234    if let Some(i) = interp {
6235        phdrs.push((
6236            3,
6237            interp_offset as u64,
6238            interp_offset as u64,
6239            i.len() as u64 + 1,
6240        ));
6241    }
6242    if has_dynamic {
6243        phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
6244    }
6245    for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
6246        let base = phoff + idx * phentsize;
6247        out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
6248        out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
6249        out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
6250        out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
6251    }
6252
6253    out[body_start..total_len].copy_from_slice(&body);
6254    out
6255}
6256
6257#[cfg(test)]
6258mod tests {
6259    use super::*;
6260    use murmur_artifact::security_warnings::W_SEC_003;
6261
6262    /// Content check only: this asserts the *constant*, not the security property. Spawning a
6263    /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
6264    /// enforcement, so it would read as evidence while proving nothing.
6265    ///
6266    /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
6267    /// breaks every subprocess invocation.
6268    #[test]
6269    fn fd_hygiene_range_starts_above_stdio() {
6270        assert_eq!(
6271            FD_HYGIENE_FIRST_FD, 3,
6272            "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
6273             survive execve"
6274        );
6275    }
6276
6277    /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
6278    /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
6279    /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
6280    /// runs without fd hygiene.
6281    ///
6282    /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
6283    /// argument validation before it touches any descriptor — so this exercises the errno path
6284    /// without closing or flagging a single fd of the test process. A kernel older than 5.11
6285    /// rejects the production call with a different errno and the identical convention — see the
6286    /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
6287    #[cfg(target_os = "linux")]
6288    #[allow(unsafe_code)]
6289    #[test]
6290    fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
6291        // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
6292        // bit is set here, so the kernel rejects the call on argument validation and no descriptor
6293        // of this process is closed or marked close-on-exec.
6294        let rc = unsafe {
6295            libc::syscall(
6296                libc::SYS_close_range,
6297                FD_HYGIENE_FIRST_FD,
6298                libc::c_uint::MAX,
6299                libc::c_uint::MAX,
6300            )
6301        };
6302
6303        assert_eq!(rc, -1, "an invalid flag word must not be accepted");
6304        assert_eq!(
6305            std::io::Error::last_os_error().raw_os_error(),
6306            Some(libc::EINVAL),
6307            "the two statements the production path runs on failure must carry the kernel's errno"
6308        );
6309    }
6310
6311    /// A host whose probe created all three namespaces — read under the AppArmor restriction with
6312    /// `mur` unconfined, because the readings never decide anything.
6313    fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
6314        crate::namespace_probe::NamespaceReading {
6315            network: Ok(()),
6316            mount: crate::namespace_probe::MountAttempt::Created,
6317            sealed_root: crate::namespace_probe::MountAttempt::Created,
6318            private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
6319            userns_grant: crate::sealed::UsernsGrant::Withheld,
6320            attachment: crate::sealed::ProfileAttachment::Unconfined,
6321        }
6322    }
6323
6324    /// A host whose probe could not run at all, which claims nothing.
6325    fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
6326        crate::namespace_probe::NamespaceReading {
6327            network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
6328                "the namespace creator did not run".to_string(),
6329            )),
6330            mount: crate::namespace_probe::MountAttempt::NotAttempted,
6331            sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
6332            private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
6333            userns_grant: crate::sealed::UsernsGrant::Withheld,
6334            attachment: crate::sealed::ProfileAttachment::NotReported,
6335        }
6336    }
6337
6338    /// `mur doctor`'s shell mechanism is read from the decision on the reading it is handed: on a
6339    /// sealed-capable reading a capsule declaring `scoped` runs its shell commands on
6340    /// `landlock+seccomp` and one declaring `sealed` on the composed root.
6341    #[test]
6342    fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
6343        if !cfg!(target_os = "linux") {
6344            return;
6345        }
6346        let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
6347        assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
6348        assert_eq!(
6349            crate::containment::applied_mechanism(
6350                &sealed_host,
6351                murmur_artifact::ContainmentClass::Scoped
6352            ),
6353            "landlock+seccomp"
6354        );
6355        assert_eq!(
6356            crate::containment::applied_mechanism(
6357                &sealed_host,
6358                murmur_artifact::ContainmentClass::Sealed
6359            ),
6360            "mountns+pivot_root+landlock+seccomp"
6361        );
6362    }
6363
6364    /// A probe that learned nothing: no Landlock answer and a namespace probe that could not run.
6365    /// The reading a host gives when neither half of the probe could run.
6366    fn unprobed() -> HostProbe {
6367        HostProbe::from_parts(None, sealed_incapable())
6368    }
6369
6370    /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
6371    /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
6372    /// tier is decided from a reading.
6373    #[test]
6374    fn an_unprobed_host_resolves_to_seccomp_only() {
6375        let tier = unprobed().tier();
6376        if cfg!(target_os = "linux") {
6377            assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
6378        } else {
6379            assert_eq!(tier, EnforcementTier::EnvironmentOnly);
6380        }
6381        assert_ne!(tier, EnforcementTier::KernelSealed);
6382        assert_ne!(tier, EnforcementTier::KernelFull);
6383    }
6384
6385    /// A reading that demonstrated nothing must still name what is missing: a refusal with no
6386    /// blocker is a refusal an operator cannot act on.
6387    #[test]
6388    fn an_unprobed_host_names_a_blocker() {
6389        assert!(unprobed().sealed_shortfall().is_some());
6390    }
6391
6392    /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
6393    /// so nothing downstream can read a failed probe as a met floor.
6394    #[test]
6395    fn an_unprobed_host_achieves_less_than_sealed() {
6396        let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
6397        assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
6398    }
6399
6400    /// The whole point of the direction: a capsule declaring `sealed` against a reading that
6401    /// demonstrated nothing is refused, with the shortfall naming the mechanism.
6402    #[test]
6403    fn an_unprobed_host_refuses_a_declared_sealed_floor() {
6404        let probe = unprobed();
6405        let achieved = crate::containment::achieved_class_for_tier(probe.tier());
6406        let error = crate::containment::check_containment_floor(
6407            murmur_artifact::ContainmentClass::Sealed,
6408            achieved,
6409            probe.sealed_shortfall().as_ref(),
6410            false,
6411            None,
6412        )
6413        .expect_err("an unprobed host cannot back a declared sealed floor");
6414        assert!(matches!(
6415            error,
6416            crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
6417        ));
6418    }
6419
6420    /// The probe succeeding while the AppArmor setting reads `1`: all three attempts created,
6421    /// `mur` unconfined or confined by a container's profile. The host backs `sealed`, maps
6422    /// `/tmp`, and is refused nothing.
6423    #[test]
6424    fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
6425        for attachment in [
6426            crate::sealed::ProfileAttachment::Unconfined,
6427            crate::sealed::ProfileAttachment::Other {
6428                profile: "docker-default".to_string(),
6429            },
6430        ] {
6431            let probe = HostProbe::from_parts(
6432                Some(true),
6433                crate::namespace_probe::NamespaceReading {
6434                    attachment,
6435                    ..sealed_capable()
6436                },
6437            );
6438            if !cfg!(target_os = "linux") {
6439                assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
6440                continue;
6441            }
6442            assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
6443            assert_eq!(probe.sealed_shortfall(), None);
6444            assert!(probe.mount_namespace_usable());
6445            assert_eq!(probe.egress_namespace_failure(), None);
6446            assert_eq!(
6447                crate::temp_dir::temp_mapping(
6448                    EnforcementTier::KernelFull,
6449                    probe.mount_namespace_usable(),
6450                    Path::new("/home/u/w"),
6451                ),
6452                crate::temp_dir::TempMapping::MountNamespace
6453            );
6454        }
6455    }
6456
6457    /// No decision reads the grant: over every reading of the restriction, the tier, the
6458    /// shortfall, the `/tmp` question and the network failure are the same — for a probe that
6459    /// created everything, and for one that created only the network and mount namespaces.
6460    #[test]
6461    fn every_decision_is_identical_across_every_userns_grant() {
6462        use crate::namespace_probe::{
6463            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6464        };
6465        let root_refused = NamespaceReading {
6466            sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6467                step: MountStep::PivotRoot,
6468                errno: libc::EPERM,
6469            }),
6470            ..sealed_capable()
6471        };
6472        for base in [sealed_capable(), root_refused, sealed_incapable()] {
6473            let decide = |grant: crate::sealed::UsernsGrant| {
6474                let probe = HostProbe::from_parts(
6475                    Some(true),
6476                    NamespaceReading {
6477                        userns_grant: grant,
6478                        ..base.clone()
6479                    },
6480                );
6481                (
6482                    probe.tier(),
6483                    probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
6484                    probe.mount_namespace_usable(),
6485                    probe
6486                        .egress_namespace_failure()
6487                        .map(|failure| failure.blocker),
6488                )
6489            };
6490            let expected = decide(crate::sealed::UsernsGrant::Withheld);
6491            for grant in crate::sealed::UsernsGrant::ALL {
6492                assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
6493            }
6494        }
6495    }
6496
6497    /// On whatever host this runs, one live reading is `KernelSealed` exactly when it names no
6498    /// `sealed` shortfall, maps `/tmp` exactly when the network and mount attempts created their
6499    /// namespaces, and reports `E-CAP-005` exactly when the network attempt failed.
6500    #[test]
6501    fn a_live_reading_answers_every_question_from_its_own_attempts() {
6502        let probe = HostProbe::probe();
6503        let namespaces = probe.namespaces().clone();
6504        let linux = cfg!(target_os = "linux");
6505        assert_eq!(
6506            probe.tier() == EnforcementTier::KernelSealed,
6507            probe.sealed_shortfall().is_none(),
6508            "{namespaces:?}"
6509        );
6510        assert_eq!(
6511            probe.mount_namespace_usable(),
6512            linux
6513                && namespaces.network.is_ok()
6514                && namespaces.mount == crate::namespace_probe::MountAttempt::Created
6515        );
6516        assert_eq!(
6517            probe.egress_namespace_failure().is_some(),
6518            linux && namespaces.network.is_err()
6519        );
6520    }
6521
6522    #[test]
6523    fn tier_from_probe_non_linux_is_always_environment_only() {
6524        for landlock in [None, Some(true), Some(false)] {
6525            for namespaces in [sealed_capable(), sealed_incapable()] {
6526                assert_eq!(
6527                    tier_from_probe(false, landlock, &namespaces),
6528                    EnforcementTier::EnvironmentOnly
6529                );
6530            }
6531        }
6532    }
6533
6534    #[test]
6535    fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
6536        assert_eq!(
6537            tier_from_probe(true, Some(true), &sealed_incapable()),
6538            EnforcementTier::KernelFull
6539        );
6540    }
6541
6542    #[test]
6543    fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
6544        assert_eq!(
6545            tier_from_probe(true, Some(false), &sealed_incapable()),
6546            EnforcementTier::KernelSeccompOnly
6547        );
6548    }
6549
6550    #[test]
6551    fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
6552        assert_eq!(
6553            tier_from_probe(true, None, &sealed_incapable()),
6554            EnforcementTier::KernelSeccompOnly
6555        );
6556    }
6557
6558    /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a fully
6559    /// enforced Landlock ruleset and a sealed-root rehearsal that built the composed root — and
6560    /// drops to exactly the tier the missing element still supports, never further.
6561    #[test]
6562    fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
6563        use crate::namespace_probe::{
6564            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6565        };
6566
6567        assert_eq!(
6568            tier_from_probe(true, Some(true), &sealed_capable()),
6569            EnforcementTier::KernelSealed
6570        );
6571
6572        // A refused network namespace attempts nothing after it: `scoped`, not `advisory`.
6573        let network_refused = NamespaceReading {
6574            network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6575                step: crate::network_namespace::CreationStep::IdentityMaps,
6576                errno: libc::EPERM,
6577            }),
6578            mount: MountAttempt::NotAttempted,
6579            sealed_root: MountAttempt::NotAttempted,
6580            ..sealed_capable()
6581        };
6582        assert_eq!(
6583            tier_from_probe(true, Some(true), &network_refused),
6584            EnforcementTier::KernelFull
6585        );
6586
6587        // Every way the sealed-root rehearsal can fail falls back to KernelFull, never below.
6588        for step in [
6589            MountStep::JoinSession,
6590            MountStep::UnshareUserMount,
6591            MountStep::IdentityMaps,
6592            MountStep::MakePrivateNested,
6593            MountStep::Tmpfs,
6594            MountStep::PivotPrepare,
6595            MountStep::PivotRoot,
6596            MountStep::DetachOldRoot,
6597        ] {
6598            let reading = NamespaceReading {
6599                sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6600                    step,
6601                    errno: libc::EPERM,
6602                }),
6603                ..sealed_capable()
6604            };
6605            assert_eq!(
6606                tier_from_probe(true, Some(true), &reading),
6607                EnforcementTier::KernelFull,
6608                "a sealed root refused at {step:?} must fall back to KernelFull"
6609            );
6610        }
6611
6612        // The `/tmp` attempt is not the composed root's: refusing it leaves `sealed` reachable.
6613        let tmp_refused = NamespaceReading {
6614            mount: MountAttempt::Failed(MountAttemptError::Step {
6615                step: MountStep::MakePrivate,
6616                errno: libc::EACCES,
6617            }),
6618            ..sealed_capable()
6619        };
6620        assert_eq!(
6621            tier_from_probe(true, Some(true), &tmp_refused),
6622            EnforcementTier::KernelSealed
6623        );
6624
6625        // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
6626        // cannot reach sealed no matter how good its namespaces are.
6627        assert_eq!(
6628            tier_from_probe(true, Some(false), &sealed_capable()),
6629            EnforcementTier::KernelSeccompOnly
6630        );
6631        assert_eq!(
6632            tier_from_probe(true, None, &sealed_capable()),
6633            EnforcementTier::KernelSeccompOnly
6634        );
6635
6636        // Not Linux at all.
6637        assert_eq!(
6638            tier_from_probe(false, Some(true), &sealed_capable()),
6639            EnforcementTier::EnvironmentOnly
6640        );
6641    }
6642
6643    /// `/tmp` follows the probe's mount attempt: refused at `MS_REC|MS_PRIVATE` it is not mapped;
6644    /// created with the sealed root refused it is mapped at `KernelFull`; with the network
6645    /// namespace refused there is no mount namespace to map it in.
6646    #[test]
6647    fn the_tmp_mapping_follows_the_mount_attempt() {
6648        use crate::namespace_probe::{
6649            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6650        };
6651        use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
6652        if !cfg!(target_os = "linux") {
6653            return;
6654        }
6655        let workdir = Path::new("/home/u/w");
6656        let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
6657
6658        let mount_refused = HostProbe::from_parts(
6659            Some(true),
6660            NamespaceReading {
6661                mount: refused(MountStep::MakePrivate, libc::EACCES),
6662                sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
6663                ..sealed_capable()
6664            },
6665        );
6666        assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
6667        assert_eq!(
6668            temp_mapping(
6669                mount_refused.tier(),
6670                mount_refused.mount_namespace_usable(),
6671                workdir
6672            ),
6673            TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
6674        );
6675
6676        let root_refused = HostProbe::from_parts(
6677            Some(true),
6678            NamespaceReading {
6679                sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
6680                ..sealed_capable()
6681            },
6682        );
6683        assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
6684        assert_eq!(
6685            temp_mapping(
6686                root_refused.tier(),
6687                root_refused.mount_namespace_usable(),
6688                workdir
6689            ),
6690            TempMapping::MountNamespace
6691        );
6692
6693        let network_refused = HostProbe::from_parts(
6694            Some(true),
6695            NamespaceReading {
6696                network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6697                    step: crate::network_namespace::CreationStep::Unshare,
6698                    errno: libc::EPERM,
6699                }),
6700                mount: MountAttempt::NotAttempted,
6701                sealed_root: MountAttempt::NotAttempted,
6702                ..sealed_capable()
6703            },
6704        );
6705        assert!(!network_refused.mount_namespace_usable());
6706        assert!(matches!(
6707            temp_mapping(
6708                network_refused.tier(),
6709                network_refused.mount_namespace_usable(),
6710                workdir
6711            ),
6712            TempMapping::NotMapped(_)
6713        ));
6714        assert!(network_refused.egress_namespace_failure().is_some());
6715    }
6716
6717    /// The ruleset ABI is the kernel's, capped at v3, on the two Landlock tiers only.
6718    #[test]
6719    fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
6720        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6721            for (kernel, built) in [
6722                (Some(1), Some(1)),
6723                (Some(2), Some(2)),
6724                (Some(3), Some(3)),
6725                (Some(8), Some(3)),
6726                (Some(u8::MAX), Some(3)),
6727            ] {
6728                assert_eq!(
6729                    ruleset_landlock_abi(kernel, tier),
6730                    built,
6731                    "kernel ABI {kernel:?} on {tier:?}"
6732                );
6733            }
6734        }
6735    }
6736
6737    /// No Landlock on the kernel, or a tier that installs no Landlock domain, builds at no ABI.
6738    #[test]
6739    fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
6740        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6741            assert_eq!(ruleset_landlock_abi(None, tier), None);
6742            assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
6743        }
6744        for tier in [
6745            EnforcementTier::KernelSeccompOnly,
6746            EnforcementTier::EnvironmentOnly,
6747        ] {
6748            for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
6749                assert_eq!(
6750                    ruleset_landlock_abi(kernel, tier),
6751                    None,
6752                    "kernel ABI {kernel:?} on {tier:?}"
6753                );
6754            }
6755        }
6756    }
6757
6758    /// `ShellEnforcement::resolve` carries the ruleset ABI of the probe it is handed, keyed on the
6759    /// applied tier, and an all-negative probe carries none.
6760    #[test]
6761    fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
6762        let negative = unprobed();
6763        for abi in [None, Some(1), Some(8)] {
6764            let enforcement = ShellEnforcement::resolve_declared(
6765                &CapabilityPolicy::default(),
6766                murmur_artifact::ContainmentClass::Advisory,
6767                negative.clone().with_landlock_kernel_abi(abi),
6768            )
6769            .unwrap()
6770            .with_test_session_dirs();
6771            assert_eq!(
6772                enforcement.landlock_abi,
6773                ruleset_landlock_abi(abi, enforcement.tier),
6774                "kernel ABI {abi:?}"
6775            );
6776            // An all-negative probe resolves below the Landlock tiers on every OS.
6777            assert_eq!(enforcement.landlock_abi, None);
6778        }
6779        if cfg!(target_os = "linux") {
6780            let full = HostProbe::from_parts(Some(true), sealed_incapable());
6781            let enforcement = ShellEnforcement::resolve_declared(
6782                &CapabilityPolicy::default(),
6783                murmur_artifact::ContainmentClass::Scoped,
6784                full.with_landlock_kernel_abi(Some(8)),
6785            )
6786            .unwrap()
6787            .with_test_session_dirs();
6788            assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
6789            assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
6790        }
6791    }
6792
6793    /// The `mur doctor` Landlock block for each kernel ABI that changes what it says.
6794    #[test]
6795    fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
6796        if !cfg!(target_os = "linux") {
6797            for abi in [None, Some(1), Some(8)] {
6798                assert_eq!(
6799                    landlock_doctor_lines(abi),
6800                    vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
6801                );
6802            }
6803            return;
6804        }
6805
6806        let none = landlock_doctor_lines(None).join("\n");
6807        assert!(none.contains("no Landlock on this host"), "{none}");
6808        assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
6809
6810        let v1 = landlock_doctor_lines(Some(1)).join("\n");
6811        assert!(v1.contains("kernel ABI: 1"), "{v1}");
6812        assert!(v1.contains("ruleset ABI: 1"), "{v1}");
6813        assert!(v1.contains("EXDEV"), "{v1}");
6814        assert!(v1.contains("Invalid cross-device link"), "{v1}");
6815        assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
6816        assert!(v1.contains("uv"), "{v1}");
6817
6818        let v2 = landlock_doctor_lines(Some(2)).join("\n");
6819        assert!(v2.contains("kernel ABI: 2"), "{v2}");
6820        assert!(v2.contains("ruleset ABI: 2"), "{v2}");
6821        assert!(!v2.contains("EXDEV"), "{v2}");
6822        assert!(
6823            v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
6824            "{v2}"
6825        );
6826
6827        for kernel in [3, 8] {
6828            let lines = landlock_doctor_lines(Some(kernel));
6829            assert_eq!(
6830                lines,
6831                vec![
6832                    format!("kernel ABI: {kernel}"),
6833                    "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
6834                ]
6835            );
6836        }
6837    }
6838
6839    /// The unconfined stance is the unconfined tier on every host, and the tier prints none of
6840    /// the tier warnings: the launch prints `W-SEC-036` instead.
6841    #[test]
6842    fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
6843        use murmur_artifact::ContainmentClass;
6844        for host_tier in [
6845            EnforcementTier::KernelSealed,
6846            EnforcementTier::KernelFull,
6847            EnforcementTier::KernelSeccompOnly,
6848            EnforcementTier::EnvironmentOnly,
6849        ] {
6850            assert_eq!(
6851                applied_tier(host_tier, ContainmentClass::Unconfined),
6852                EnforcementTier::Unconfined,
6853                "{host_tier:?}"
6854            );
6855        }
6856        assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
6857        assert_eq!(
6858            ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
6859            None
6860        );
6861    }
6862
6863    /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
6864    /// could give it more. Silently installing a composed root under a `scoped` declaration would
6865    /// delete host paths its `interpreter_runtime` grants legitimately name.
6866    #[test]
6867    fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
6868        use murmur_artifact::ContainmentClass;
6869
6870        assert_eq!(
6871            applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
6872            EnforcementTier::KernelSealed
6873        );
6874        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
6875            assert_eq!(
6876                applied_tier(EnforcementTier::KernelSealed, declared),
6877                EnforcementTier::KernelFull,
6878                "declaring {declared} must not opt a capsule into the composed root"
6879            );
6880        }
6881
6882        // Every other tier is passed through untouched, whatever was declared — the declared floor
6883        // can only ever weaken the applied mechanism, never strengthen it past the host.
6884        for host_tier in [
6885            EnforcementTier::KernelFull,
6886            EnforcementTier::KernelSeccompOnly,
6887            EnforcementTier::EnvironmentOnly,
6888        ] {
6889            for declared in [
6890                ContainmentClass::Advisory,
6891                ContainmentClass::Scoped,
6892                ContainmentClass::Sealed,
6893            ] {
6894                assert_eq!(applied_tier(host_tier, declared), host_tier);
6895            }
6896        }
6897    }
6898
6899    #[test]
6900    fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
6901        use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
6902
6903        let policy = CapabilityPolicy {
6904            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6905                binary: "python3".to_string(),
6906                dirs: vec![
6907                    // Already inside /usr, which the fixed list binds wholesale.
6908                    InterpreterRuntimeDir {
6909                        path: "/usr/lib/python3.11".to_string(),
6910                        list_dir: true,
6911                    },
6912                    InterpreterRuntimeDir {
6913                        path: "/opt/py/lib".to_string(),
6914                        list_dir: true,
6915                    },
6916                    // A duplicate, and a relative path that names nothing absolute.
6917                    InterpreterRuntimeDir {
6918                        path: "/opt/py/lib".to_string(),
6919                        list_dir: false,
6920                    },
6921                    InterpreterRuntimeDir {
6922                        path: "relative/lib".to_string(),
6923                        list_dir: false,
6924                    },
6925                ],
6926            }],
6927            ..CapabilityPolicy::default()
6928        };
6929        let exec_allow = vec![
6930            PathBuf::from("/usr/bin/python3"),
6931            PathBuf::from("/opt/toolchain/bin/cc"),
6932        ];
6933
6934        assert_eq!(
6935            resolve_sealed_bind_dirs(&exec_allow, &policy),
6936            vec![
6937                PathBuf::from("/opt/toolchain/bin"),
6938                PathBuf::from("/opt/py/lib")
6939            ]
6940        );
6941    }
6942
6943    /// The execute roots a host path is checked against include each `interpreter_runtime`
6944    /// directory and each `staged_runtime` tree: a read-write entry inside the first is refused on
6945    /// both Landlock mechanisms, and one containing the second is refused at `scoped` and bound
6946    /// read-only beneath on `sealed`. Pure: none of the paths need exist.
6947    #[test]
6948    fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
6949        use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
6950        let policy = CapabilityPolicy {
6951            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6952                binary: "python3".to_string(),
6953                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
6954                    path: "/nonexistent-host-path-test/interp".to_string(),
6955                    list_dir: true,
6956                }],
6957            }],
6958            shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
6959                binary: "python3".to_string(),
6960                source_path: "/nonexistent-host-path-test/opt/py".to_string(),
6961                pin: "pin-1".to_string(),
6962            }],
6963            ..CapabilityPolicy::default()
6964        };
6965        let roots = execute_roots_from(&[], &policy);
6966        let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
6967        assert_eq!(
6968            dirs,
6969            [
6970                Path::new("/nonexistent-host-path-test/interp"),
6971                Path::new("/nonexistent-host-path-test/opt/py"),
6972            ]
6973        );
6974        let read_write = |path: &str| HostPath {
6975            declared: path.to_string(),
6976            path: PathBuf::from(path),
6977            access: HostPathAccess::ReadWrite,
6978            credential_locations: Vec::new(),
6979        };
6980
6981        let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
6982        for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
6983            assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
6984        }
6985
6986        let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
6987        assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
6988        assert_eq!(
6989            check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
6990            [PathBuf::from("/nonexistent-host-path-test/opt/py")]
6991        );
6992    }
6993
6994    /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
6995    /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
6996    /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
6997    /// relative path — which cannot be re-based under the composed root at all — is skipped.
6998    #[test]
6999    fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
7000        use murmur_artifact::StagedRuntimeGrant;
7001
7002        let grant = |binary: &str, source: &str| StagedRuntimeGrant {
7003            binary: binary.to_string(),
7004            source_path: source.to_string(),
7005            pin: "pin-1".to_string(),
7006        };
7007        let policy = CapabilityPolicy {
7008            shell_staged_runtime: vec![
7009                grant("python3", "/opt/py"),
7010                // Inside /usr, which the fixed list already binds. Kept anyway — unlike
7011                // `resolve_sealed_bind_dirs`, which drops exactly this case.
7012                grant("perl", "/usr/lib/perl5"),
7013                // A duplicate of the first, and a relative path naming nothing absolute.
7014                grant("python3.12", "/opt/py"),
7015                grant("ruby", "relative/ruby"),
7016            ],
7017            ..CapabilityPolicy::default()
7018        };
7019
7020        assert_eq!(
7021            resolve_staged_runtime_dirs(&policy),
7022            vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
7023        );
7024    }
7025
7026    /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
7027    /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
7028    /// must therefore cover the same directories.
7029    #[test]
7030    fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
7031        use murmur_artifact::StagedRuntimeGrant;
7032
7033        let policy = CapabilityPolicy {
7034            shell_staged_runtime: vec![StagedRuntimeGrant {
7035                binary: "python3".to_string(),
7036                source_path: "/opt/py".to_string(),
7037                pin: "pin-1".to_string(),
7038            }],
7039            ..CapabilityPolicy::default()
7040        };
7041
7042        let dirs = resolve_staged_runtime_dirs(&policy);
7043        let grants = resolve_staged_runtime_landlock_grants(&policy);
7044
7045        assert_eq!(
7046            grants,
7047            vec![LandlockGrant {
7048                path: PathBuf::from("/opt/py"),
7049                list_dir: true,
7050                executable: true,
7051            }],
7052            "a staged tree is walked by the runtime that uses it, so it must be listable",
7053        );
7054        assert_eq!(
7055            grants
7056                .iter()
7057                .map(|grant| grant.path.clone())
7058                .collect::<Vec<_>>(),
7059            dirs,
7060            "the bound set and the granted set must not drift apart",
7061        );
7062    }
7063
7064    #[test]
7065    fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
7066        let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
7067
7068        assert_eq!(
7069            grants,
7070            crate::sealed::SEALED_RUNTIME_PATHS
7071                .iter()
7072                .map(|path| LandlockGrant {
7073                    path: PathBuf::from(path),
7074                    list_dir: true,
7075                    executable: false,
7076                })
7077                .collect::<Vec<_>>(),
7078            "the composed root binds exactly these, so exactly these must be enumerable — \
7079             the bound set and the granted set must not drift apart",
7080        );
7081        assert!(
7082            grants.iter().all(|grant| grant.list_dir),
7083            "a runtime tree an interpreter walks is useless without getdents64",
7084        );
7085        assert!(
7086            grants.iter().all(|grant| !grant.executable),
7087            "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
7088             sealed capsule run every binary the host ships, whatever shell.allow says",
7089        );
7090    }
7091
7092    #[test]
7093    fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
7094        for tier in [
7095            EnforcementTier::KernelFull,
7096            EnforcementTier::KernelSeccompOnly,
7097            EnforcementTier::EnvironmentOnly,
7098        ] {
7099            assert!(
7100                resolve_sealed_runtime_landlock_grants(tier).is_empty(),
7101                "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
7102                 would enumerate the real host filesystem",
7103            );
7104        }
7105    }
7106
7107    /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
7108    /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
7109    /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
7110    /// is the only honest thing a test can do about a kernel capability it may not have.
7111    #[test]
7112    fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
7113        let policy = CapabilityPolicy::default();
7114        let usr = PathBuf::from("/usr");
7115
7116        let scoped = ShellEnforcement::resolve_declared(
7117            &policy,
7118            murmur_artifact::ContainmentClass::Scoped,
7119            HostProbe::probe(),
7120        )
7121        .expect("an empty policy resolves")
7122        .with_test_session_dirs();
7123        assert!(
7124            !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
7125            "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
7126             unenumerable there",
7127        );
7128
7129        let sealed = ShellEnforcement::resolve_declared(
7130            &policy,
7131            murmur_artifact::ContainmentClass::Sealed,
7132            HostProbe::probe(),
7133        )
7134        .expect("an empty policy resolves")
7135        .with_test_session_dirs();
7136        let granted: Vec<&LandlockGrant> = sealed
7137            .landlock_grants
7138            .iter()
7139            .filter(|grant| {
7140                crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
7141            })
7142            .collect();
7143        if sealed.tier == EnforcementTier::KernelSealed {
7144            assert_eq!(
7145                granted.len(),
7146                crate::sealed::SEALED_RUNTIME_PATHS.len(),
7147                "a sealed session must carry one listable grant per bound runtime path",
7148            );
7149            assert!(granted
7150                .iter()
7151                .all(|grant| grant.list_dir && !grant.executable));
7152        } else {
7153            assert!(
7154                granted.is_empty(),
7155                "this host fell back to {:?}, where there is no composed root to enumerate",
7156                sealed.tier,
7157            );
7158        }
7159    }
7160
7161    #[test]
7162    fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
7163        let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
7164
7165        assert_eq!(
7166            grants,
7167            crate::sealed::SEALED_ETC_PATHS
7168                .iter()
7169                .map(|entry| LandlockGrant {
7170                    path: PathBuf::from(entry.path),
7171                    list_dir: entry.directory,
7172                    executable: false,
7173                })
7174                .collect::<Vec<_>>(),
7175            "the composed root binds exactly these /etc entries, so exactly these must be \
7176             readable — the bound set and the granted set must not drift apart",
7177        );
7178        assert!(
7179            grants.iter().all(|grant| !grant.executable),
7180            "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
7181             run — /etc/alternatives is a directory of symlinks into /usr/bin",
7182        );
7183
7184        // The `directory` classification itself, spelled out independently of the constant so a
7185        // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
7186        // regular file) shows up as a test failure rather than as a TLS bug on a real host.
7187        let listable: Vec<&str> = grants
7188            .iter()
7189            .filter(|grant| grant.list_dir)
7190            .map(|grant| grant.path.to_str().unwrap())
7191            .collect();
7192        assert_eq!(
7193            listable,
7194            vec![
7195                "/etc/ld.so.conf.d",
7196                "/etc/alternatives",
7197                "/etc/ssl",
7198                "/etc/pki",
7199                "/etc/ca-certificates",
7200                "/etc/terminfo",
7201            ],
7202            "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
7203             the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
7204        );
7205    }
7206
7207    #[test]
7208    fn sealed_etc_landlock_grants_are_sealed_tier_only() {
7209        for tier in [
7210            EnforcementTier::KernelFull,
7211            EnforcementTier::KernelSeccompOnly,
7212            EnforcementTier::EnvironmentOnly,
7213        ] {
7214            assert!(
7215                resolve_sealed_etc_landlock_grants(tier).is_empty(),
7216                "{tier:?} has no composed root, so these rules would hand out the real host's \
7217                 /etc — its trust store, resolv.conf and account databases",
7218            );
7219        }
7220    }
7221
7222    /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
7223    /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
7224    /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
7225    /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
7226    #[test]
7227    fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
7228        let policy = CapabilityPolicy::default();
7229        let is_etc_grant = |grant: &LandlockGrant| {
7230            crate::sealed::SEALED_ETC_PATHS
7231                .iter()
7232                .any(|entry| Path::new(entry.path) == grant.path)
7233        };
7234
7235        let scoped = ShellEnforcement::resolve_declared(
7236            &policy,
7237            murmur_artifact::ContainmentClass::Scoped,
7238            HostProbe::probe(),
7239        )
7240        .expect("an empty policy resolves")
7241        .with_test_session_dirs();
7242        // The two name-resolution files are the one exception, granted read-only by
7243        // `resolve_scoped_name_resolution_landlock_grants`; nothing else of the list is.
7244        let name_resolution = |grant: &LandlockGrant| {
7245            SCOPED_NAME_RESOLUTION_PATHS
7246                .iter()
7247                .any(|path| Path::new(path) == grant.path)
7248        };
7249        assert!(
7250            !scoped
7251                .landlock_grants
7252                .iter()
7253                .any(|grant| is_etc_grant(grant) && !name_resolution(grant)),
7254            "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
7255             neighbours must not become readable there",
7256        );
7257        assert_eq!(
7258            scoped
7259                .landlock_grants
7260                .iter()
7261                .filter(|grant| name_resolution(grant))
7262                .count(),
7263            SCOPED_NAME_RESOLUTION_PATHS.len()
7264        );
7265
7266        let sealed = ShellEnforcement::resolve_declared(
7267            &policy,
7268            murmur_artifact::ContainmentClass::Sealed,
7269            HostProbe::probe(),
7270        )
7271        .expect("an empty policy resolves")
7272        .with_test_session_dirs();
7273        let granted: Vec<&LandlockGrant> = sealed
7274            .landlock_grants
7275            .iter()
7276            .filter(|grant| is_etc_grant(grant))
7277            .collect();
7278        if sealed.tier == EnforcementTier::KernelSealed {
7279            assert_eq!(
7280                granted.len(),
7281                crate::sealed::SEALED_ETC_PATHS.len(),
7282                "a sealed session must carry one read grant per bound /etc entry",
7283            );
7284            assert!(granted.iter().all(|grant| !grant.executable));
7285        } else {
7286            // A fallback to `scoped` grants what `scoped` grants: the name-resolution files and
7287            // nothing else of the host's own `/etc`.
7288            assert!(
7289                granted
7290                    .iter()
7291                    .all(|grant| name_resolution(grant) && !grant.executable && !grant.list_dir),
7292                "this host fell back to {:?}, where there is no composed root and /etc is the \
7293                 host's own: {granted:?}",
7294                sealed.tier,
7295            );
7296            assert_eq!(
7297                granted.len(),
7298                resolve_scoped_name_resolution_landlock_grants(sealed.tier).len(),
7299                "{:?}: {granted:?}",
7300                sealed.tier,
7301            );
7302        }
7303    }
7304
7305    #[test]
7306    fn resolve_network_allowlist_ips_empty_list_is_empty() {
7307        let ips = resolve_network_allowlist_ips(&[]).unwrap();
7308        assert!(ips.is_empty());
7309    }
7310
7311    #[test]
7312    fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
7313        let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
7314        assert!(
7315            !ips.is_empty(),
7316            "localhost should resolve to at least one address"
7317        );
7318        assert!(
7319            ips.iter().all(|ip| ip.is_loopback()),
7320            "every resolved localhost address should be loopback, got {ips:?}"
7321        );
7322    }
7323
7324    #[test]
7325    fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
7326        let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
7327        assert!(!error.is_empty());
7328    }
7329
7330    /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
7331    /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
7332    /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
7333    /// to resolve, which keeps this test independent of the host's DNS.
7334    #[test]
7335    fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
7336        let ips =
7337            resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
7338                .expect("an unresolvable host must not be a hard error");
7339        assert!(
7340            ips.is_empty(),
7341            "an unresolvable host should contribute no IPs, got {ips:?}"
7342        );
7343    }
7344
7345    /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
7346    /// loop continues rather than aborting on the first miss.
7347    #[test]
7348    fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
7349        let ips = resolve_network_allowlist_ips(&[
7350            "definitely-not-a-real-host.invalid".to_string(),
7351            "localhost".to_string(),
7352        ])
7353        .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
7354
7355        assert!(
7356            !ips.is_empty(),
7357            "localhost's addresses should survive alongside an unresolvable host"
7358        );
7359        assert!(
7360            ips.iter().all(|ip| ip.is_loopback()),
7361            "only localhost should have contributed addresses, got {ips:?}"
7362        );
7363    }
7364
7365    #[test]
7366    fn resolve_network_allowlist_ips_dedupes_across_hosts() {
7367        // Two entries that both resolve to loopback should not produce duplicate IPs beyond
7368        // what's actually distinct.
7369        let ips =
7370            resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
7371                .unwrap();
7372        let mut sorted = ips.clone();
7373        sorted.sort();
7374        sorted.dedup();
7375        assert_eq!(
7376            ips.len(),
7377            sorted.len(),
7378            "resolved IPs should already be deduplicated"
7379        );
7380    }
7381
7382    #[test]
7383    fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
7384        let enforcement = ShellEnforcement::environment_only();
7385        assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
7386        assert!(enforcement.network_allow_ips.is_empty());
7387    }
7388
7389    /// With no scope a spawn's `RLIMIT_NPROC` is headroom over a real count. A `0` baseline
7390    /// would turn `max_processes` into an absolute per-uid ceiling that an interactive account
7391    /// is already past, failing the subprocess's first `fork()`.
7392    #[cfg(any(target_os = "linux", target_os = "macos"))]
7393    #[test]
7394    fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
7395        match ShellEnforcement::environment_only()
7396            .with_test_session_dirs()
7397            .nproc_bound()
7398        {
7399            crate::resources::NprocBound::Headroom { baseline } => assert!(
7400                baseline > 0,
7401                "the test process is owned by this uid, so the count cannot be zero"
7402            ),
7403            other => panic!("no scope must give headroom, got {other:?}"),
7404        }
7405    }
7406
7407    /// The baseline is taken on each call, not when the enforcement was built: threads the host
7408    /// starts in between are counted by the next spawn.
7409    #[cfg(target_os = "linux")]
7410    #[test]
7411    fn nproc_bound_without_a_scope_is_measured_on_each_call() {
7412        let _lock = crate::resources::test_support::host_threads_lock();
7413        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7414        let baseline = |bound| match bound {
7415            crate::resources::NprocBound::Headroom { baseline } => baseline,
7416            other => panic!("no scope must give headroom, got {other:?}"),
7417        };
7418
7419        let first = baseline(enforcement.nproc_bound());
7420        let holders = crate::resources::test_support::HeldThreads::start(200);
7421        let second = baseline(enforcement.nproc_bound());
7422        drop(holders);
7423
7424        assert!(
7425            second > first,
7426            "200 threads started between the calls must raise the baseline: {first} then {second}"
7427        );
7428    }
7429
7430    /// A spawn that will join a live scope leaves `RLIMIT_NPROC` to `pids.max`.
7431    #[cfg(target_os = "linux")]
7432    #[test]
7433    fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
7434        if !crate::cgroup::cgroup_delegation_available() {
7435            crate::runtime_err!(
7436                "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
7437                 cannot delegate a cgroup v2 scope"
7438            );
7439            return;
7440        }
7441        let temp = tempfile::tempdir().unwrap();
7442        let limits = crate::resources::HostResourceLimits::default();
7443        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
7444            .expect("this host delegates a cgroup scope")
7445            .scope;
7446        let enforcement = ShellEnforcement::environment_only()
7447            .with_test_session_dirs()
7448            .with_host_bounding(scope, None);
7449
7450        assert_eq!(
7451            enforcement.nproc_bound(),
7452            crate::resources::NprocBound::CgroupPidsMax
7453        );
7454    }
7455
7456    /// A Linux capsule with a cgroup scope gets no `RLIMIT_NPROC` from the runtime, so threads the
7457    /// rest of the host starts after the enforcement was resolved cannot spend its fork headroom.
7458    /// The 400 holders put the uid well past any `RLIMIT_NPROC` derived from a count taken before
7459    /// they started, which fails the first `fork()` in the subprocess with `EAGAIN`. The 40
7460    /// concurrent `sleep`s and the concurrent `node` children each need headroom well past the
7461    /// declared `max_processes` of 32.
7462    #[cfg(target_os = "linux")]
7463    #[test]
7464    fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
7465        const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
7466        if !crate::cgroup::cgroup_delegation_available() {
7467            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
7468            return;
7469        }
7470        let _lock = crate::resources::test_support::host_threads_lock();
7471
7472        let temp = tempfile::tempdir().unwrap();
7473        let limits = crate::resources::HostResourceLimits {
7474            max_processes: 32,
7475            max_open_files: 256,
7476            ..crate::resources::HostResourceLimits::default()
7477        };
7478        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
7479            .expect("this host delegates a cgroup scope")
7480            .scope;
7481        assert!(
7482            scope.is_some(),
7483            "a required scope on Linux is always created"
7484        );
7485        let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
7486        let mut shell_allow = vec!["bash".to_string()];
7487        if node_available {
7488            shell_allow.push("node".to_string());
7489        }
7490        let policy = CapabilityPolicy {
7491            shell_allow,
7492            resources: limits,
7493            ..CapabilityPolicy::default()
7494        };
7495        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7496        enforcement.resource_limits = limits;
7497        let enforcement = enforcement.with_host_bounding(scope, None);
7498
7499        let holders = crate::resources::test_support::HeldThreads::start(400);
7500
7501        let result = crate::shell::execute_shell(
7502            "bash",
7503            &[
7504                "-c",
7505                "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
7506            ],
7507            &[],
7508            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7509            &policy,
7510            &enforcement,
7511        )
7512        .expect("bash must run");
7513        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7514        assert_eq!(result.resource_limit_hit, None);
7515        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7516        let lines: Vec<&str> = result.stdout.lines().collect();
7517        assert_eq!(
7518            lines,
7519            [
7520                crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
7521                "256"
7522            ],
7523            "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
7524        );
7525
7526        if node_available {
7527            let script = "const { spawn } = require('child_process');\n\
7528                const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
7529                  const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
7530                  child.once('error', (error) => resolve(error.message));\n\
7531                  child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
7532                }));\n\
7533                Promise.all(runs).then((outcomes) => {\n\
7534                  const failures = outcomes.filter((outcome) => outcome !== null);\n\
7535                  if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
7536                });\n";
7537            let result = crate::shell::execute_shell(
7538                "node",
7539                &["-e", script],
7540                &[],
7541                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7542                &policy,
7543                &enforcement,
7544            )
7545            .expect("node must run");
7546            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7547        } else {
7548            crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
7549        }
7550
7551        drop(holders);
7552    }
7553
7554    /// Without a scope `RLIMIT_NPROC` is the only process bound, so its baseline has to follow the
7555    /// host: 300 threads started after the enforcement was resolved raise the next spawn's
7556    /// ceiling, and releasing them lowers the one after.
7557    #[cfg(target_os = "linux")]
7558    #[test]
7559    fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
7560        const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
7561        let _lock = crate::resources::test_support::host_threads_lock();
7562
7563        let temp = tempfile::tempdir().unwrap();
7564        let policy = CapabilityPolicy {
7565            shell_allow: vec!["bash".to_string()],
7566            resources: crate::resources::HostResourceLimits {
7567                max_processes: 64,
7568                ..crate::resources::HostResourceLimits::default()
7569            },
7570            ..CapabilityPolicy::default()
7571        };
7572        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7573        enforcement.resource_limits = policy.resources;
7574
7575        let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
7576        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7577        if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
7578            crate::runtime_err!(
7579                "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
7580                 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
7581            );
7582            return;
7583        }
7584
7585        let hard_nproc = || -> u64 {
7586            let result = crate::shell::execute_shell(
7587                "bash",
7588                &["-c", "ulimit -Hu"],
7589                &[],
7590                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7591                &policy,
7592                &enforcement,
7593            )
7594            .expect("bash must run");
7595            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7596            result.stdout.trim().parse().expect("a finite ceiling")
7597        };
7598
7599        let holders = crate::resources::test_support::HeldThreads::start(300);
7600        let held = hard_nproc();
7601        assert!(
7602            held >= before + 64 + 150,
7603            "the ceiling must count the holder threads started after the enforcement was \
7604             resolved: {held} with {before} tasks before them"
7605        );
7606
7607        drop(holders);
7608        let released = hard_nproc();
7609        assert!(
7610            released < held,
7611            "the ceiling must drop once the holders exit: {released} after, {held} while held"
7612        );
7613    }
7614
7615    fn make_executable(path: &Path, contents: &str) {
7616        use std::os::unix::fs::PermissionsExt;
7617        std::fs::write(path, contents).unwrap();
7618        let mut perms = std::fs::metadata(path).unwrap().permissions();
7619        perms.set_mode(0o755);
7620        std::fs::set_permissions(path, perms).unwrap();
7621    }
7622
7623    fn bootstrap_log_contents(workdir: &Path) -> String {
7624        std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
7625    }
7626
7627    // ---- exec allowlist resolution (identity, not names) ----
7628
7629    #[test]
7630    fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
7631        let temp = tempfile::tempdir().unwrap();
7632        let bin_dir = temp.path().join("bin");
7633        std::fs::create_dir(&bin_dir).unwrap();
7634        let tool = bin_dir.join("mytool");
7635        make_executable(&tool, "#!/bin/sh\nexit 0\n");
7636
7637        let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
7638        assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
7639    }
7640
7641    #[test]
7642    fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
7643        let temp = tempfile::tempdir().unwrap();
7644        let resolved = resolve_exec_allowlist_in(
7645            &["definitely-not-a-real-binary".to_string(), String::new()],
7646            &[temp.path().to_path_buf()],
7647        );
7648        assert!(
7649            resolved.is_empty(),
7650            "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
7651        );
7652    }
7653
7654    #[test]
7655    fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
7656        let temp = tempfile::tempdir().unwrap();
7657        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7658        let resolved =
7659            resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
7660        assert!(resolved.is_empty());
7661    }
7662
7663    #[test]
7664    fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
7665        let temp = tempfile::tempdir().unwrap();
7666        let real = temp.path().join("real-tool");
7667        make_executable(&real, "#!/bin/sh\nexit 0\n");
7668        let bin_dir = temp.path().join("bin");
7669        std::fs::create_dir(&bin_dir).unwrap();
7670        std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
7671
7672        let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
7673        assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
7674    }
7675
7676    // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
7677
7678    #[test]
7679    fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
7680        let temp = tempfile::tempdir().unwrap();
7681        let bin_dir = temp.path().join("bin");
7682        std::fs::create_dir(&bin_dir).unwrap();
7683        let tool = bin_dir.join("mytool");
7684        make_executable(&tool, "#!/bin/sh\nexit 0\n");
7685
7686        let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
7687        assert_eq!(
7688            resolved,
7689            std::fs::canonicalize(&tool)
7690                .unwrap()
7691                .to_string_lossy()
7692                .into_owned(),
7693            "a resolvable name must report the canonical absolute path, not the bare name"
7694        );
7695    }
7696
7697    #[test]
7698    fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
7699        let temp = tempfile::tempdir().unwrap();
7700        let path_dirs = [temp.path().to_path_buf()];
7701        let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
7702        assert_eq!(
7703            resolved, "definitely-not-a-real-binary",
7704            "an unresolvable name must fall back to the bare name, never error"
7705        );
7706        assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
7707    }
7708
7709    #[test]
7710    fn resolve_invoked_binary_path_takes_the_first_path_match() {
7711        let temp = tempfile::tempdir().unwrap();
7712        let first = temp.path().join("first");
7713        let second = temp.path().join("second");
7714        std::fs::create_dir(&first).unwrap();
7715        std::fs::create_dir(&second).unwrap();
7716        make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
7717        make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
7718
7719        let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
7720        assert_eq!(
7721            resolved,
7722            std::fs::canonicalize(first.join("mytool"))
7723                .unwrap()
7724                .to_string_lossy()
7725                .into_owned(),
7726            "reporting must match execvp: the first PATH hit is the one that ran"
7727        );
7728    }
7729
7730    #[test]
7731    fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
7732        let temp = tempfile::tempdir().unwrap();
7733        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7734        assert_eq!(
7735            resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
7736            "mytool"
7737        );
7738    }
7739
7740    #[test]
7741    fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
7742        let temp = tempfile::tempdir().unwrap();
7743        let real = temp.path().join("real-tool");
7744        make_executable(&real, "#!/bin/sh\nexit 0\n");
7745        let link = temp.path().join("link-tool");
7746        std::os::unix::fs::symlink(&real, &link).unwrap();
7747
7748        assert_eq!(
7749            resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
7750            std::fs::canonicalize(&real)
7751                .unwrap()
7752                .to_string_lossy()
7753                .into_owned()
7754        );
7755    }
7756
7757    // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
7758
7759    #[test]
7760    fn parse_elf_extracts_interp_needed_and_runpath() {
7761        let bytes = build_elf64(
7762            Some("/lib64/ld-linux-x86-64.so.2"),
7763            &["libc.so.6", "libm.so.6"],
7764            Some("$ORIGIN/../lib:/opt/custom/lib"),
7765        );
7766        let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
7767        assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
7768        assert_eq!(
7769            deps.needed,
7770            vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
7771        );
7772        assert_eq!(
7773            deps.runpaths,
7774            vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
7775            "DT_RUNPATH must be split on ':' with empty parts dropped"
7776        );
7777    }
7778
7779    #[test]
7780    fn parse_elf_static_binary_contributes_only_itself() {
7781        // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
7782        let bytes = build_elf64(None, &[], None);
7783        let deps =
7784            parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
7785        assert_eq!(deps, ElfDependencies::default());
7786    }
7787
7788    #[test]
7789    fn parse_elf_rejects_non_elf_and_non_elf64() {
7790        assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
7791        // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
7792        let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
7793        elf32[4] = 1;
7794        assert!(
7795            parse_elf_dependencies(elf32.as_slice()).is_none(),
7796            "32-bit ELF is not a binary this runtime execs and must not parse"
7797        );
7798    }
7799
7800    /// Every ELF image of this host's `python3`, `perl`, `bash` and `git` closures and every file
7801    /// in python's `lib-dynload`, and truncated and malformed copies of each binary, parse the same
7802    /// read from memory and read by position from the file, `None` included.
7803    #[cfg(target_os = "linux")]
7804    #[test]
7805    #[allow(clippy::print_stdout)]
7806    fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
7807        let mut binaries = Vec::new();
7808        for name in ["python3", "perl", "bash", "git"] {
7809            match find_on_path(name) {
7810                Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
7811                None => println!("[SKIP] no {name} on PATH"),
7812            }
7813        }
7814        let mut images: std::collections::BTreeSet<PathBuf> =
7815            resolve_landlock_grants(&binaries).into_iter().collect();
7816        let dynload = std::process::Command::new("python3")
7817            .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
7818            .output()
7819            .ok()
7820            .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
7821            .unwrap_or_default();
7822        for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
7823            for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
7824                images.insert(entry.path());
7825            }
7826        }
7827        let compare = |path: &Path| {
7828            let Ok(bytes) = std::fs::read(path) else {
7829                return;
7830            };
7831            let file = ElfFile::open_host(path).unwrap();
7832            assert_eq!(
7833                parse_elf_dependencies(bytes.as_slice()),
7834                parse_elf_dependencies(&file),
7835                "{}",
7836                path.display()
7837            );
7838            assert_eq!(
7839                elf_dynamic_symbol_names(bytes.as_slice()),
7840                elf_dynamic_symbol_names(&file),
7841                "{}",
7842                path.display()
7843            );
7844            assert_eq!(
7845                elf_is_shared_object(bytes.as_slice()),
7846                elf_is_shared_object(&file),
7847                "{}",
7848                path.display()
7849            );
7850        };
7851        for path in &images {
7852            compare(path);
7853        }
7854        println!("compared {} host images", images.len());
7855
7856        // Truncated and malformed copies: cut inside each table the parsers read, and with the
7857        // header's table offsets pointed past the end of the file.
7858        let temp = tempfile::tempdir().unwrap();
7859        let mut variants = 0;
7860        for binary in &binaries {
7861            let bytes = std::fs::read(binary).unwrap();
7862            let mut cuts = vec![
7863                0,
7864                1,
7865                17,
7866                18,
7867                63,
7868                64,
7869                100,
7870                4096,
7871                bytes.len() / 2,
7872                bytes.len() - 1,
7873            ];
7874            for field in [32usize, 40] {
7875                if let Some(offset) = read_u64(&bytes, field, true) {
7876                    cuts.push(offset as usize + 30);
7877                }
7878            }
7879            let mut copies: Vec<Vec<u8>> = cuts
7880                .into_iter()
7881                .filter(|cut| *cut <= bytes.len())
7882                .map(|cut| bytes[..cut].to_vec())
7883                .collect();
7884            for field in [32usize, 40] {
7885                let mut moved = bytes.clone();
7886                moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
7887                copies.push(moved);
7888            }
7889            let mut big_endian = bytes.clone();
7890            big_endian[5] = 2;
7891            copies.push(big_endian);
7892            for copy in copies {
7893                let path = temp.path().join(format!("variant-{variants}"));
7894                std::fs::write(&path, &copy).unwrap();
7895                compare(&path);
7896                variants += 1;
7897            }
7898        }
7899        println!("compared {variants} malformed images");
7900    }
7901
7902    /// Resolving a binary's closure reads its headers and tables, not the whole file: a stand-in
7903    /// padded to 64 MiB costs a few KiB.
7904    #[cfg(target_os = "linux")]
7905    #[test]
7906    #[allow(clippy::print_stdout)]
7907    fn resolving_a_closure_reads_no_binary_in_full() {
7908        let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
7909        let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
7910            return;
7911        };
7912        crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
7913        let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
7914        let before = crate::interpreter_runtime::tests::thread_rchar();
7915        let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
7916        let read = crate::interpreter_runtime::tests::thread_rchar() - before;
7917        assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
7918        assert!(grants.contains(&stand_in));
7919        assert!(
7920            read < 8 * 1024 * 1024,
7921            "resolving the closure read {read} bytes"
7922        );
7923        println!("resolving the padded stand-in's closure read {read} bytes");
7924    }
7925
7926    // ---- soname resolution (loader search-order, synthetic fixtures) ----
7927
7928    #[test]
7929    fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
7930        let temp = tempfile::tempdir().unwrap();
7931        let origin = temp.path().join("app");
7932        std::fs::create_dir(&origin).unwrap();
7933        let libs = temp.path().join("libs");
7934        std::fs::create_dir(&libs).unwrap();
7935        std::fs::write(libs.join("libc.so.6"), "x").unwrap();
7936
7937        // Found in a standard search directory.
7938        assert_eq!(
7939            resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
7940            Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
7941        );
7942        // Unresolvable soname → None (shrink-not-fail).
7943        assert_eq!(
7944            resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
7945            None
7946        );
7947
7948        // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
7949        let origin_lib = origin.join("lib");
7950        std::fs::create_dir(&origin_lib).unwrap();
7951        std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
7952        assert_eq!(
7953            resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
7954            Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
7955        );
7956    }
7957
7958    // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
7959
7960    #[test]
7961    fn resolve_landlock_grants_derives_interp_and_needed_closure() {
7962        let temp = tempfile::tempdir().unwrap();
7963        let bin_dir = temp.path().join("bin");
7964        let lib_dir = temp.path().join("lib");
7965        std::fs::create_dir(&bin_dir).unwrap();
7966        std::fs::create_dir(&lib_dir).unwrap();
7967
7968        // A fake interpreter and a fake shared library, both outside any "workdir".
7969        let interp = bin_dir.join("ld-fake.so");
7970        std::fs::write(&interp, "not really elf").unwrap();
7971        std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
7972
7973        // A synthetic dynamically-linked binary naming that interp + that soname.
7974        let prog = bin_dir.join("prog");
7975        let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
7976        std::fs::write(&prog, &elf).unwrap();
7977
7978        let grants =
7979            resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
7980
7981        let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
7982        assert!(
7983            grants.contains(&expect(prog)),
7984            "the binary itself must be granted: {grants:?}"
7985        );
7986        assert!(
7987            grants.contains(&expect(interp)),
7988            "the ELF interpreter must be granted: {grants:?}"
7989        );
7990        assert!(
7991            grants.contains(&expect(lib_dir.join("libfake.so.1"))),
7992            "each resolved DT_NEEDED library must be granted: {grants:?}"
7993        );
7994    }
7995
7996    #[test]
7997    fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
7998        let temp = tempfile::tempdir().unwrap();
7999        let bin_dir = temp.path().join("bin");
8000        std::fs::create_dir(&bin_dir).unwrap();
8001
8002        // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
8003        let prog = bin_dir.join("prog");
8004        std::fs::write(
8005            &prog,
8006            build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
8007        )
8008        .unwrap();
8009        let missing = bin_dir.join("does-not-exist");
8010
8011        let grants =
8012            resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
8013
8014        // Only the real binary survives; the missing binary, missing interp, and unresolvable
8015        // soname each contribute nothing (shrink-not-fail).
8016        assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
8017    }
8018
8019    // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
8020
8021    fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
8022        InterpreterRuntimeGrant {
8023            binary: binary.to_string(),
8024            dirs: dirs
8025                .iter()
8026                .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
8027                    path: path.to_string(),
8028                    list_dir: *list_dir,
8029                })
8030                .collect(),
8031        }
8032    }
8033
8034    #[test]
8035    fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
8036        // Two directories under the same binary, opposite `list_dir` — proving enumerability is
8037        // per-directory and copied exactly, not inferred or unified.
8038        let grants = resolve_interpreter_runtime_grants(&[interp_grant(
8039            "python3",
8040            &[
8041                ("/usr/lib/python3.11", true),
8042                ("/usr/lib/python3.11/lib-dynload", false),
8043            ],
8044        )]);
8045
8046        assert_eq!(
8047            grants,
8048            vec![
8049                LandlockGrant {
8050                    path: PathBuf::from("/usr/lib/python3.11"),
8051                    list_dir: true,
8052                    executable: true,
8053                },
8054                LandlockGrant {
8055                    path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
8056                    list_dir: false,
8057                    executable: true,
8058                },
8059            ]
8060        );
8061    }
8062
8063    #[test]
8064    fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
8065        // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
8066        // scope` skips any that fail to open), and multiple grants flatten in order.
8067        let grants = resolve_interpreter_runtime_grants(&[
8068            interp_grant("python3", &[("/opt/py/nonexistent", false)]),
8069            interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
8070        ]);
8071
8072        assert_eq!(
8073            grants,
8074            vec![
8075                LandlockGrant {
8076                    path: PathBuf::from("/opt/py/nonexistent"),
8077                    list_dir: false,
8078                    executable: true,
8079                },
8080                LandlockGrant {
8081                    path: PathBuf::from("/opt/rb/also-nonexistent"),
8082                    list_dir: true,
8083                    executable: true,
8084                },
8085            ]
8086        );
8087        assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
8088    }
8089
8090    #[test]
8091    fn non_listable_files_marks_every_closure_file_non_enumerable() {
8092        // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
8093        // (ReadDir on a regular file is a no-op anyway).
8094        let wrapped = LandlockGrant::non_listable_files(vec![
8095            PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8096            PathBuf::from("/usr/bin/bash"),
8097        ]);
8098        assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
8099        assert_eq!(
8100            wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
8101            vec![
8102                PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8103                PathBuf::from("/usr/bin/bash"),
8104            ]
8105        );
8106    }
8107
8108    // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
8109    //
8110    // The canonical statement of what the *content* checks in this file are worth, referenced by
8111    // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
8112    // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
8113    // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
8114    // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
8115    // buys one thing: changing a constant cannot happen by accident.
8116    //
8117    // The enforcement claim for this constant is verified only by the manual procedure in
8118    // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
8119    // uncontainerized Linux hardware.
8120
8121    #[test]
8122    fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
8123        let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
8124            .iter()
8125            .map(|device| (device.path, device.writable))
8126            .collect();
8127        assert_eq!(
8128            listed,
8129            vec![
8130                ("/dev/null", true),
8131                ("/dev/zero", false),
8132                ("/dev/urandom", false),
8133            ],
8134            "the capsule device set is fixed at exactly these three paths and these access \
8135             levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
8136             and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
8137             both. Widening this list is allowed only on demonstrated workload failure, and the \
8138             failure gets recorded alongside the widening (see the manual acceptance procedure on \
8139             the diagnostics reference page)."
8140        );
8141    }
8142
8143    #[test]
8144    fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
8145        // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
8146        // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
8147        // shell child has dropped). It is excluded because nothing needs a blocking RNG when
8148        // /dev/urandom is granted — not because writing to it would poison the kernel pool.
8149        for excluded in [
8150            "/dev/random",
8151            "/dev/full",
8152            "/dev/tty",
8153            "/dev/console",
8154            "/dev/mem",
8155            "/dev/sda",
8156            "/dev",
8157        ] {
8158            assert!(
8159                !CAPSULE_DEVICE_GRANTS
8160                    .iter()
8161                    .any(|device| device.path == excluded),
8162                "{excluded} must not be in the capsule device set — it is denied by the ordinary \
8163                 no-matching-rule path, and adding it here would be the only way to change that"
8164            );
8165        }
8166    }
8167
8168    #[test]
8169    fn capsule_device_grants_name_absolute_paths_under_dev() {
8170        // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
8171        // against whatever cwd the host process happens to have.
8172        for device in CAPSULE_DEVICE_GRANTS {
8173            assert!(
8174                device.path.starts_with("/dev/"),
8175                "{} must be an absolute path under /dev",
8176                device.path
8177            );
8178        }
8179    }
8180
8181    // ---- `denied_socket_domains` ----------------------------------------------------------
8182    //
8183    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
8184    // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
8185    // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
8186    // real Linux hardware.
8187
8188    #[test]
8189    fn denied_socket_domains_denies_unix_by_default() {
8190        let denied = denied_socket_domains(false);
8191        assert!(
8192            denied.contains(&LINUX_AF_UNIX),
8193            "AF_UNIX must be denied when the manifest does not declare \
8194             capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
8195        );
8196    }
8197
8198    #[test]
8199    fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
8200        let denied = denied_socket_domains(true);
8201        assert!(
8202            !denied.contains(&LINUX_AF_UNIX),
8203            "a capsule that declared capabilities.network.unix_sockets: true must get no \
8204             AF_UNIX deny rule at all"
8205        );
8206    }
8207
8208    /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
8209    /// identical for both values of the one flag that exists.
8210    #[test]
8211    fn denied_socket_domains_always_denies_netlink_and_packet() {
8212        for unix_sockets_allowed in [false, true] {
8213            let denied = denied_socket_domains(unix_sockets_allowed);
8214            assert!(
8215                denied.contains(&LINUX_AF_NETLINK),
8216                "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
8217                 {unix_sockets_allowed})"
8218            );
8219            assert!(
8220                denied.contains(&LINUX_AF_PACKET),
8221                "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
8222                 {unix_sockets_allowed})"
8223            );
8224        }
8225    }
8226
8227    /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
8228    /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
8229    /// deny.
8230    #[test]
8231    fn denied_socket_domains_never_denies_ip_families() {
8232        for unix_sockets_allowed in [false, true] {
8233            let denied = denied_socket_domains(unix_sockets_allowed);
8234            assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
8235            assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
8236        }
8237    }
8238
8239    /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
8240    /// an unnoticed side effect of touching the function.
8241    #[test]
8242    fn denied_socket_domains_returns_exactly_the_documented_sets() {
8243        assert_eq!(
8244            denied_socket_domains(false),
8245            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
8246        );
8247        assert_eq!(
8248            denied_socket_domains(true),
8249            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
8250        );
8251    }
8252
8253    /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
8254    /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
8255    /// they really do agree with `libc`; on macOS this test compiles away, because
8256    /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
8257    /// constants are literals.
8258    #[cfg(target_os = "linux")]
8259    #[test]
8260    fn linux_socket_domain_constants_match_libc() {
8261        assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
8262        assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
8263        assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
8264        assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
8265        assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
8266    }
8267
8268    /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
8269    /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
8270    /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
8271    /// anything stated here.
8272    #[test]
8273    fn socket_domain_allow_and_deny_sets_never_overlap() {
8274        for unix_sockets_allowed in [false, true] {
8275            let denied = denied_socket_domains(unix_sockets_allowed);
8276            for domain in allowed_socket_domains(unix_sockets_allowed) {
8277                assert!(
8278                    !denied.contains(&domain),
8279                    "socket domain {domain} is both allowed and denied \
8280                     (unix_sockets_allowed = {unix_sockets_allowed})"
8281                );
8282            }
8283        }
8284    }
8285
8286    /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
8287    /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
8288    /// default-deny filter would silently keep unix sockets refused even after a capsule declared
8289    /// the capability.
8290    #[test]
8291    fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
8292        assert_eq!(
8293            allowed_socket_domains(false),
8294            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
8295        );
8296        assert_eq!(
8297            allowed_socket_domains(true),
8298            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
8299        );
8300    }
8301
8302    // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
8303    //
8304    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
8305    // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
8306    // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
8307    // the allowlist against a newer upstream profile.
8308
8309    #[test]
8310    fn allowlist_contains_no_syscall_that_must_stay_denied() {
8311        for name in SECCOMP_MUST_STAY_DENIED {
8312            assert!(
8313                !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
8314                "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
8315                 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
8316                 in the same edit and say why in the commit"
8317            );
8318        }
8319    }
8320
8321    /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
8322    /// so omitting them would refuse the child's own first `execve` and no capsule could run a
8323    /// shell tool at all. Exec is decided by the Landlock domain instead; see
8324    /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
8325    #[test]
8326    fn allowlist_permits_exec_because_landlock_now_decides_it() {
8327        for name in ["execve", "execveat"] {
8328            assert!(
8329                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8330                "{name} must reach the kernel — the default action is a deny, so omitting it \
8331                 refuses the child's own exec and nothing runs"
8332            );
8333        }
8334    }
8335
8336    /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
8337    /// for a syscall by discarding every conditional chain already recorded for it. A bare
8338    /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
8339    /// denials at filter-build time, with nothing at runtime to show for it.
8340    #[test]
8341    fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
8342        assert!(
8343            !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
8344            "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
8345        );
8346    }
8347
8348    /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
8349    /// which is exactly the state where one of them later gets removed and the other is missed.
8350    #[test]
8351    fn allowlist_has_no_duplicates() {
8352        let mut seen = std::collections::HashSet::new();
8353        for name in SECCOMP_SYSCALL_ALLOWLIST {
8354            assert!(seen.insert(*name), "{name} appears twice in the allowlist");
8355        }
8356    }
8357
8358    /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
8359    /// still "pass" every other test here, while denying every syscall a shell needs.
8360    #[test]
8361    fn allowlist_covers_the_syscalls_no_process_can_run_without() {
8362        for name in [
8363            "read",
8364            "write",
8365            "openat",
8366            "close",
8367            "mmap",
8368            "munmap",
8369            "brk",
8370            "exit_group",
8371            "futex",
8372            "rt_sigreturn",
8373            "clone",
8374            "wait4",
8375            "getpid",
8376            "getdents64",
8377        ] {
8378            assert!(
8379                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8380                "{name} is missing from the allowlist — no process could run under this filter"
8381            );
8382        }
8383    }
8384
8385    #[test]
8386    fn network_ip_allowed_matches_only_the_resolved_allowlist() {
8387        let allow = vec![IpAddr::from([127, 0, 0, 1])];
8388        assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
8389        assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
8390        assert!(
8391            !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
8392            "an empty resolved allowlist must deny every destination"
8393        );
8394    }
8395
8396    // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
8397
8398    #[test]
8399    fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
8400        // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
8401        // namespace, and a silent tier would imply it has no documented limits. It warns when a
8402        // shell is declared, and stays silent only when there is no subprocess to enforce on.
8403        assert_eq!(
8404            tier_warning(EnforcementTier::KernelFull, false),
8405            Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
8406        );
8407        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8408    }
8409
8410    /// The sealed notice states what `/proc` is: the session's private procfs, with no host
8411    /// process in it, and the host's only where no private one can be mounted, then refused by
8412    /// Landlock with `stat(2)` on `/proc/<pid>` the one thing that still reaches outside.
8413    #[test]
8414    fn tier_warning_kernel_sealed_states_what_proc_is() {
8415        let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
8416        assert_eq!(code, W_SEC_005);
8417        assert!(!notice.contains("no PID namespace"), "{notice}");
8418        assert!(notice.contains("own PID namespace"), "{notice}");
8419        assert!(notice.contains("private procfs"), "{notice}");
8420        assert!(notice.contains("no host process appears"), "{notice}");
8421        assert!(
8422            notice.contains("Landlock refuses every open beneath it"),
8423            "{notice}"
8424        );
8425        assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
8426        assert!(!notice.contains("any process of the same user"), "{notice}");
8427        assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
8428    }
8429
8430    #[test]
8431    fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
8432        assert_eq!(
8433            tier_warning(EnforcementTier::KernelSeccompOnly, false),
8434            Some((W_SEC_002, SECCOMP_ONLY_WARNING))
8435        );
8436        assert_eq!(
8437            tier_warning(EnforcementTier::EnvironmentOnly, false),
8438            Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
8439        );
8440        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8441        assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
8442        assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
8443    }
8444
8445    /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
8446    /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
8447    /// same condition refuses the launch outright, so a warning would describe a session that
8448    /// does not exist — and never for a capsule with no process tree to bound.
8449    #[test]
8450    fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
8451        assert_eq!(
8452            aggregate_bounding_warning(false, true, false),
8453            Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
8454        );
8455        assert_eq!(
8456            aggregate_bounding_warning(false, false, false),
8457            None,
8458            "a capsule that cannot spawn a subprocess has nothing to bound"
8459        );
8460        assert_eq!(
8461            aggregate_bounding_warning(true, true, false),
8462            None,
8463            "on Linux this case is a refused launch, not a warning"
8464        );
8465        assert_eq!(
8466            aggregate_bounding_warning(true, true, true),
8467            None,
8468            "a scope exists, so there is no gap"
8469        );
8470    }
8471
8472    /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
8473    /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
8474    /// would expect to stop a fork bomb does not.
8475    #[test]
8476    fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
8477        let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
8478        assert!(message.contains("per-uid"), "message was: {message}");
8479        assert!(message.contains("fork bomb"), "message was: {message}");
8480        assert!(message.contains("pids.max"), "message was: {message}");
8481    }
8482
8483    #[test]
8484    fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
8485        let temp = tempfile::tempdir().unwrap();
8486        warn_for_missing_aggregate_bounding(
8487            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8488            true,
8489            false,
8490        );
8491        let log = bootstrap_log_contents(temp.path());
8492
8493        if cfg!(target_os = "linux") {
8494            assert!(
8495                log.is_empty(),
8496                "Linux refuses the launch instead of warning: {log}"
8497            );
8498        } else {
8499            assert!(log.contains(W_SEC_010), "log was: {log}");
8500            assert!(
8501                log.contains(&security_warning_link(W_SEC_010)),
8502                "log was: {log}"
8503            );
8504        }
8505    }
8506
8507    fn warning_test_policy() -> CapabilityPolicy {
8508        CapabilityPolicy {
8509            shell_allow: vec!["bash".to_string()],
8510            network_allow: vec!["https://api.example.com".to_string()],
8511            ..CapabilityPolicy::default()
8512        }
8513    }
8514
8515    #[test]
8516    fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
8517        let temp = tempfile::tempdir().unwrap();
8518        warn_for_enforcement_tier(
8519            EnforcementTier::KernelFull,
8520            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8521            &warning_test_policy(),
8522        );
8523        let log = bootstrap_log_contents(temp.path());
8524        assert!(
8525            log.contains(W_SEC_005),
8526            "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
8527        );
8528        assert!(
8529            log.contains("denied, not absent"),
8530            "must state the scope's documented limit: paths outside the workdir are denied, not \
8531             absent: {log}"
8532        );
8533        assert!(
8534            log.contains("read+execute") && log.contains("outside the workdir"),
8535            "must describe what is now actually granted (narrow, derived read+execute outside \
8536             the workdir), not an unfixed grant bug: {log}"
8537        );
8538        assert!(
8539            log.contains(&security_warning_link(W_SEC_005)),
8540            "must link to the diagnostics doc page: {log}"
8541        );
8542    }
8543
8544    #[test]
8545    fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
8546        let temp = tempfile::tempdir().unwrap();
8547        warn_for_enforcement_tier(
8548            EnforcementTier::KernelFull,
8549            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8550            &CapabilityPolicy::default(),
8551        );
8552        assert_eq!(
8553            bootstrap_log_contents(temp.path()),
8554            "",
8555            "with no shell declared there is no subprocess to enforce on — stay silent"
8556        );
8557    }
8558
8559    #[test]
8560    fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
8561        let temp = tempfile::tempdir().unwrap();
8562        warn_for_enforcement_tier(
8563            EnforcementTier::KernelSeccompOnly,
8564            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8565            &warning_test_policy(),
8566        );
8567        let log = bootstrap_log_contents(temp.path());
8568        assert!(
8569            log.contains("Landlock"),
8570            "must name the missing primitive: {log}"
8571        );
8572        assert!(
8573            log.contains("filesystem"),
8574            "must name what is not enforced: {log}"
8575        );
8576        assert!(
8577            log.contains(W_SEC_002),
8578            "must carry its warning code: {log}"
8579        );
8580        assert!(
8581            log.contains(&security_warning_link(W_SEC_002)),
8582            "must link to the diagnostics doc page: {log}"
8583        );
8584    }
8585
8586    #[test]
8587    fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
8588        let temp = tempfile::tempdir().unwrap();
8589        warn_for_enforcement_tier(
8590            EnforcementTier::EnvironmentOnly,
8591            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8592            &warning_test_policy(),
8593        );
8594        let log = bootstrap_log_contents(temp.path());
8595        assert!(
8596            log.contains("permanent"),
8597            "must state the tier is permanent, not pending: {log}"
8598        );
8599        assert!(
8600            log.contains("bash"),
8601            "the still-accurate bash/network bypass warning must also fire: {log}"
8602        );
8603        assert!(
8604            log.contains(W_SEC_001),
8605            "must carry the tier warning code: {log}"
8606        );
8607        assert!(
8608            log.contains(W_SEC_003),
8609            "must carry the bash/network bypass warning code: {log}"
8610        );
8611    }
8612
8613    // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
8614
8615    struct ForcePrepareFailureGuard;
8616
8617    impl ForcePrepareFailureGuard {
8618        fn new() -> Self {
8619            FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
8620            Self
8621        }
8622    }
8623
8624    impl Drop for ForcePrepareFailureGuard {
8625        fn drop(&mut self) {
8626            FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
8627        }
8628    }
8629
8630    #[test]
8631    fn execute_shell_fails_closed_when_enforcement_setup_fails() {
8632        let temp = tempfile::tempdir().unwrap();
8633        let policy = CapabilityPolicy {
8634            shell_allow: vec!["bash".to_string()],
8635            ..CapabilityPolicy::default()
8636        };
8637        let marker = temp.path().join("spawned-anyway.marker");
8638        let script = format!("echo ran > '{}'", marker.display());
8639
8640        {
8641            let _guard = ForcePrepareFailureGuard::new();
8642            let error = crate::shell::execute_shell(
8643                "bash",
8644                &["-c", &script],
8645                &[],
8646                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8647                &policy,
8648                &ShellEnforcement::environment_only().with_test_session_dirs(),
8649            )
8650            .unwrap_err();
8651            assert!(
8652                error.to_string().contains("sandbox"),
8653                "the error must name what failed to initialize: {error}"
8654            );
8655            assert!(
8656                !marker.exists(),
8657                "no subprocess may be spawned at all when enforcement setup fails"
8658            );
8659        }
8660
8661        // Control run without the forced failure: the identical call spawns and writes the
8662        // marker — proving the marker really would have caught a spawn above.
8663        let result = crate::shell::execute_shell(
8664            "bash",
8665            &["-c", &script],
8666            &[],
8667            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8668            &policy,
8669            &ShellEnforcement::environment_only().with_test_session_dirs(),
8670        )
8671        .unwrap();
8672        assert_eq!(result.exit_code, 0);
8673        assert!(marker.exists());
8674    }
8675}
8676
8677#[cfg(target_os = "linux")]
8678#[cfg(test)]
8679#[allow(clippy::print_stdout, clippy::print_stderr)]
8680mod landlock_probe_isolation {
8681    /// The Landlock probe must leave the calling process exactly as it found it.
8682    ///
8683    /// Stated as "the sealed probe gives the same answer before and after" rather than by
8684    /// inspecting the process's Landlock domain, because there is no API to ask whether a task
8685    /// is landlocked — and because that framing is what actually broke: the probe restricted
8686    /// itself, and every later attempt to build a composed root was refused, on every host, in
8687    /// a way that looked like the host's fault.
8688    ///
8689    /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
8690    /// halves report the same failure and this passes trivially; on a capable one, an
8691    /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
8692    #[test]
8693    fn the_landlock_probe_does_not_restrict_the_calling_process() {
8694        let before = crate::namespace_probe::probe_namespaces().sealed_root;
8695        let _ = super::linux_enforce::probe_landlock_full_access();
8696        let after = crate::namespace_probe::probe_namespaces().sealed_root;
8697
8698        assert_eq!(
8699            before, after,
8700            "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
8701             it has restricted the calling process instead of a forked child, which permanently \
8702             forbids the mount family to this process and everything it spawns",
8703        );
8704    }
8705}
8706
8707#[cfg(all(test, target_os = "linux"))]
8708#[allow(clippy::print_stdout, clippy::print_stderr)]
8709mod linux_integration_tests {
8710    use std::path::Path;
8711
8712    use super::*;
8713
8714    /// Spread-base supplying the three *host-bounding* fields (`resource_limits`,
8715    /// `cgroup_scope`, `workdir_guard`) to the enforcement literals below, which spell out the
8716    /// policy half by hand because they need to pin `tier` explicitly.
8717    ///
8718    /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
8719    /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
8720    /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
8721    /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
8722    /// of the child `pre_exec` runs on every tier), and no cgroup scope or workdir guard, so each
8723    /// spawn gets `RLIMIT_NPROC` headroom over the uid's count at that spawn. The `tier` it
8724    /// carries is always overridden by the literal that spreads it; only the host-bounding tail
8725    /// is ever consumed from here.
8726    fn host_bounding_base() -> ShellEnforcement {
8727        // Every session holds a runtime directory, and a sealed one cannot compose a root
8728        // without it. Kept past the test: a clone may outlive the value.
8729        let base = tempfile::tempdir().unwrap().keep();
8730        ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
8731    }
8732
8733    /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
8734    /// It pins *which* rights of [`LANDLOCK_TARGET_ABI`] `apply_landlock_scope` hands the workdir,
8735    /// so re-adding device-node creation, or losing the move and truncate rights, becomes a test
8736    /// failure instead of a silent regression.
8737    ///
8738    /// It proves **nothing** about whether the kernel enforces that set — that is the manual
8739    /// acceptance procedure in
8740    /// `docs/content/reference/workdir-device-node-manual-verification.md` and the
8741    /// `kernel_tier_*` tests below, not this test.
8742    /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
8743    /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
8744    #[test]
8745    fn workdir_landlock_grant_withholds_device_and_socket_creation() {
8746        use landlock::{Access, AccessFs, ABI};
8747
8748        assert_eq!(
8749            LANDLOCK_TARGET_ABI, 3,
8750            "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
8751        );
8752        let all_v3 = AccessFs::from_all(ABI::V3);
8753        // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
8754        // unconditionally-withheld rights below are checked against the permissive case rather
8755        // than passing trivially because `Execute` happened to be absent too.
8756        let granted = linux_enforce::workdir_access_rights(true);
8757
8758        for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
8759            assert!(
8760                all_v3.contains(withheld),
8761                "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
8762                 test's premise (and the workdir grant's comment) needs revisiting"
8763            );
8764            assert!(
8765                !granted.contains(withheld),
8766                "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
8767                 by handle_access, withholding it here is what makes it denied rather than merely \
8768                 un-granted"
8769            );
8770        }
8771
8772        for added in [AccessFs::Refer, AccessFs::Truncate] {
8773            assert!(
8774                granted.contains(added),
8775                "the workdir grant must carry {added:?}: without Refer a move between workdir \
8776                 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
8777                 capsule's own files"
8778            );
8779        }
8780
8781        assert!(
8782            granted.contains(AccessFs::MakeFifo),
8783            "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
8784        );
8785
8786        let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
8787        assert_eq!(
8788            granted, expected,
8789            "the workdir_exec grant must be exactly Landlock ABI v3 minus \
8790             MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
8791             this test and the constant's comment together"
8792        );
8793    }
8794
8795    /// The workdir right set as a ruleset built at ABI v1 sees it, after `apply_landlock_scope`
8796    /// masks it to the handled set: exactly ABI v1 minus the three `Make*` rights, with neither
8797    /// `Refer` nor `Truncate`, so a 5.13–5.18 kernel is handed no right it does not know.
8798    #[test]
8799    fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
8800        use landlock::{Access, AccessFs, ABI};
8801
8802        let all_v1 = AccessFs::from_all(ABI::V1);
8803        let masked = linux_enforce::workdir_access_rights(true) & all_v1;
8804
8805        assert_eq!(
8806            masked,
8807            all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
8808            "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
8809        );
8810        assert!(!masked.contains(AccessFs::Refer));
8811        assert!(!masked.contains(AccessFs::Truncate));
8812        assert_eq!(
8813            linux_enforce::workdir_access_rights(false) & all_v1,
8814            masked & !AccessFs::Execute,
8815            "masking must not move the workdir_exec axis"
8816        );
8817    }
8818
8819    /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
8820    /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
8821    /// procedure in
8822    /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
8823    /// regression that matters here: flipping the default back — which would silently reopen the
8824    /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
8825    #[test]
8826    fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
8827        use landlock::AccessFs;
8828
8829        assert!(
8830            !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
8831            "the default must withhold Execute from the workdir — that withholding is the whole \
8832             of what enforces capabilities.shell.allow now that the exec supervisor is gone"
8833        );
8834        assert!(
8835            linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
8836            "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
8837             compile-and-run workflows it exists for silently stop working"
8838        );
8839
8840        // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
8841        assert_eq!(
8842            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
8843            linux_enforce::workdir_access_rights(false),
8844            "workdir_exec must be a one-bit axis — no other right may ride along with it"
8845        );
8846    }
8847
8848    /// A sealed enforcement over a fresh runtime directory beside `accessible`.
8849    fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
8850        ShellEnforcement {
8851            tier: EnforcementTier::KernelSealed,
8852            ..ShellEnforcement::environment_only().with_test_session_dirs()
8853        }
8854        .with_runtime_dir(test_runtime_dir(base), None)
8855    }
8856
8857    /// The session's temporary folder's descriptor, as `prepare_enforcement` takes it from the
8858    /// held root.
8859    fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
8860        enforcement
8861            .runtime_dir
8862            .as_ref()
8863            .and_then(|roots| roots.temp.as_ref())
8864            .map(|temp| Arc::new(temp.path_fd().unwrap()))
8865    }
8866
8867    /// The session's home folder's descriptor, as `prepare_enforcement` takes it from the held
8868    /// root.
8869    fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
8870        enforcement
8871            .home_root()
8872            .map(|home| Arc::new(home.path_fd().unwrap()))
8873    }
8874
8875    /// The sealed sources take the session's temporary folder from the runtime directory roots,
8876    /// bind it from its own path, and add nothing to either workdir; the Landlock fds carry the
8877    /// folder's rule whenever one is handed in. Opening an fd is an ordinary `open()` — no
8878    /// Landlock call, no namespace, no privilege — so this runs on any Linux host.
8879    ///
8880    /// It proves **nothing** about whether the kernel then honors the rule built from that fd;
8881    /// the live tests below and the hand-run procedure in
8882    /// `docs/content/reference/sealed-containment-manual-verification.md` do.
8883    #[test]
8884    fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
8885        let accessible = tempfile::tempdir().unwrap();
8886        let runtime = tempfile::tempdir().unwrap();
8887        let enforcement = sealed_with_runtime_dir(runtime.path());
8888        let temp_dir_fd = temp_dir_fd_of(&enforcement);
8889        let sources = SealedSourceFds::open(
8890            &enforcement,
8891            temp_dir_fd.as_ref(),
8892            home_dir_fd_of(&enforcement).as_ref(),
8893        )
8894        .expect("a real, writable workdir must yield the sealed sources");
8895        assert_eq!(
8896            sources.tmp_store_path,
8897            enforcement.temp_dir_path().unwrap(),
8898            "the composed root binds the session's temporary folder at /tmp"
8899        );
8900        assert_eq!(
8901            sources.home_dir.1,
8902            enforcement.home_dir_path().unwrap(),
8903            "the composed root binds the session's home folder at its own path"
8904        );
8905        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
8906            sealed: Some(&sources),
8907            temp_dir_fd,
8908            home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
8909            runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
8910            records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
8911            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
8912        })
8913        .expect("a real, writable workdir must yield sealed Landlock fds");
8914
8915        assert!(fds.temp_dir_fd().is_some());
8916        assert!(
8917            fds.home_dir_fd().is_some(),
8918            "the home folder takes the temporary folder's rule"
8919        );
8920        assert!(fds.runtime_dir_fd().is_some());
8921        assert!(
8922            fds.records_dir_fd().is_some(),
8923            "the records directory takes the runtime directory's read-only rule"
8924        );
8925        assert_eq!(
8926            std::fs::read_dir(accessible.path()).unwrap().count(),
8927            0,
8928            "enabling sealed must add nothing to the work folder"
8929        );
8930
8931        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
8932            accessible.path(),
8933        ))
8934        .expect("a real, writable workdir must yield Landlock fds on any tier");
8935        assert!(
8936            fds.temp_dir_fd().is_none(),
8937            "an enforcement holding no temporary folder grants none"
8938        );
8939        assert!(fds.home_dir_fd().is_none());
8940    }
8941
8942    /// A sealed root cannot be composed without the session's temporary folder: the refusal is
8943    /// the parent's, names the folder, and comes before anything is spawned.
8944    #[test]
8945    fn a_sealed_root_needs_the_temp_folder() {
8946        let runtime = tempfile::tempdir().unwrap();
8947        let mut roots = test_runtime_dir(runtime.path());
8948        roots.temp = None;
8949        let enforcement = ShellEnforcement {
8950            tier: EnforcementTier::KernelSealed,
8951            ..ShellEnforcement::environment_only().with_test_session_dirs()
8952        }
8953        .with_runtime_dir(roots, None);
8954        let error =
8955            SealedSourceFds::open(&enforcement, None, home_dir_fd_of(&enforcement).as_ref())
8956                .err()
8957                .expect("refused");
8958        assert!(error.contains("temporary folder"), "{error}");
8959    }
8960
8961    /// Nor without its home folder: the refusal names the missing home folder, and comes before
8962    /// anything is spawned or written.
8963    #[test]
8964    fn no_home_folder_refuses_a_sealed_root() {
8965        let runtime = tempfile::tempdir().unwrap();
8966        let roots = crate::runtime_dir::RuntimeDirRoots {
8967            home: None,
8968            ..test_runtime_dir(runtime.path())
8969        };
8970        let enforcement = ShellEnforcement {
8971            tier: EnforcementTier::KernelSealed,
8972            ..ShellEnforcement::environment_only().with_test_session_dirs()
8973        }
8974        .with_runtime_dir(roots, None);
8975        let error =
8976            SealedSourceFds::open(&enforcement, temp_dir_fd_of(&enforcement).as_ref(), None)
8977                .err()
8978                .expect("refused");
8979        assert!(error.contains("no home folder"), "{error}");
8980        assert!(!runtime
8981            .path()
8982            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
8983            .join("passwd")
8984            .exists());
8985    }
8986
8987    /// The synthetic account databases, written into `sealed-etc/` beside the runtime directory
8988    /// and opened by the same parent-side call.
8989    ///
8990    /// Like the temporary-folder test above it proves nothing about kernel enforcement — that is
8991    /// the hand-run in `docs/content/reference/sealed-containment-manual-verification.md`. It
8992    /// catches that the files exist before `build_sealed_root` binds them, that there is an fd to
8993    /// build each rule from, and that nothing is written into either workdir for them.
8994    #[test]
8995    fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
8996        let accessible = tempfile::tempdir().unwrap();
8997        let runtime = tempfile::tempdir().unwrap();
8998        let enforcement = sealed_with_runtime_dir(runtime.path());
8999        let sources = SealedSourceFds::open(
9000            &enforcement,
9001            temp_dir_fd_of(&enforcement).as_ref(),
9002            home_dir_fd_of(&enforcement).as_ref(),
9003        )
9004        .unwrap();
9005        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9006            sealed: Some(&sources),
9007            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9008        })
9009        .expect("a real, writable workdir must yield sealed Landlock fds");
9010
9011        let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
9012            .iter()
9013            .filter(|entry| entry.synthetic.is_some())
9014            .collect();
9015        assert_eq!(
9016            fds.sealed_identity_fd_count(),
9017            synthetic.len(),
9018            "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
9019        );
9020        let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
9021        for entry in &synthetic {
9022            let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
9023            assert!(
9024                path.is_file(),
9025                "{} must be written into sealed-etc/",
9026                entry.path
9027            );
9028        }
9029        assert!(
9030            !accessible.path().join(".mur-etc").exists(),
9031            "the work folder gained .mur-etc"
9032        );
9033
9034        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9035            accessible.path(),
9036        ))
9037        .unwrap();
9038        assert_eq!(fds.sealed_identity_fd_count(), 0);
9039    }
9040
9041    /// A sealed root cannot be composed without the session's runtime directory: the refusal is
9042    /// the parent's, before anything is spawned.
9043    #[test]
9044    fn a_sealed_root_needs_the_runtime_directory() {
9045        let enforcement = ShellEnforcement {
9046            tier: EnforcementTier::KernelSealed,
9047            ..ShellEnforcement::environment_only()
9048        };
9049        let error = SealedSourceFds::open(&enforcement, None, None)
9050            .err()
9051            .expect("refused");
9052        assert!(error.contains("runtime directory"), "{error}");
9053    }
9054
9055    /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
9056    ///
9057    /// Both are the session's home folder's absolute path, `<records>/home`, and this asserts the
9058    /// end result — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and `os.environ["HOME"]`,
9059    /// and a disagreement between them is silent until a tool picks the wrong one. The uid/gid
9060    /// are checked against the home folder's own owner, which is where
9061    /// `ensure_sealed_identity_files` reads the capsule's identity from.
9062    #[test]
9063    fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
9064        use std::os::unix::fs::MetadataExt;
9065
9066        let runtime = tempfile::tempdir().unwrap();
9067        let roots = test_runtime_dir(runtime.path());
9068        let home = roots.home.clone().unwrap();
9069        let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
9070        let env = crate::shell::build_shell_env(
9071            &crate::types::CapabilityPolicy::default(),
9072            &[],
9073            Some(home.path()),
9074            None,
9075            None,
9076            None,
9077        )
9078        .unwrap();
9079
9080        let passwd = std::fs::read_to_string(&written[0]).unwrap();
9081        let owner = std::fs::metadata(home.path()).unwrap();
9082        let entry: Vec<&str> = passwd
9083            .lines()
9084            .last()
9085            .expect("the capsule's own entry is the last line")
9086            .split(':')
9087            .collect();
9088
9089        assert_eq!(entry[2], owner.uid().to_string(), "uid field");
9090        assert_eq!(entry[3], owner.gid().to_string(), "gid field");
9091        assert_eq!(
9092            Some(&entry[5].to_string()),
9093            env.get("HOME"),
9094            "pw_dir and $HOME are the same string or the entry is worse than useless",
9095        );
9096        assert_eq!(
9097            entry[5],
9098            runtime
9099                .path()
9100                .join(crate::records_dir::RECORDS_DIR_NAME)
9101                .join(crate::records_dir::HOME_SUBDIR)
9102                .to_str()
9103                .unwrap()
9104        );
9105
9106        let group = std::fs::read_to_string(&written[1]).unwrap();
9107        assert_eq!(
9108            group.lines().last().unwrap().split(':').nth(2),
9109            Some(owner.gid().to_string().as_str()),
9110            "gid field of the capsule's own group",
9111        );
9112    }
9113
9114    /// A workdir root over `work/` beside an `outside/` directory holding one sentinel file.
9115    fn sealed_planted() -> (
9116        tempfile::TempDir,
9117        PathBuf,
9118        PathBuf,
9119        crate::workdir_fs::WorkdirRoot,
9120    ) {
9121        let dir = tempfile::tempdir().unwrap();
9122        let accessible = dir.path().join("work");
9123        let outside = dir.path().join("outside");
9124        std::fs::create_dir_all(&accessible).unwrap();
9125        std::fs::create_dir_all(&outside).unwrap();
9126        std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
9127        let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
9128        (dir, root.path().to_path_buf(), outside, root)
9129    }
9130
9131    /// Staging twice rewrites `sealed-etc/passwd` in place: the inode a Landlock rule was bound to
9132    /// on the first call is the file the second call leaves.
9133    #[test]
9134    fn sealed_identity_files_keep_their_inode() {
9135        use std::os::unix::fs::MetadataExt;
9136
9137        let (_dir, _session, _outside, root) = sealed_planted();
9138        let runtime = tempfile::tempdir().unwrap();
9139        let roots = test_runtime_dir(runtime.path());
9140        let passwd = roots.sealed_etc.path().join("passwd");
9141        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9142        let first = std::fs::metadata(&passwd).unwrap().ino();
9143        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9144        assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
9145        assert!(root.take_refusals().is_empty());
9146    }
9147
9148    /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
9149    /// no fork, no spawn.
9150    ///
9151    /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
9152    /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
9153    /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
9154    /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
9155    /// manager creating build directories — and the `Execute` axis, which must track
9156    /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
9157    /// the same storage and a binary staged in one is the same bytes as a binary staged in the
9158    /// other.
9159    #[test]
9160    fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
9161        use landlock::AccessFs;
9162
9163        for workdir_exec in [false, true] {
9164            let rights = linux_enforce::workdir_access_rights(workdir_exec);
9165
9166            for needed in [
9167                AccessFs::WriteFile,
9168                AccessFs::ReadFile,
9169                AccessFs::ReadDir,
9170                AccessFs::MakeReg,
9171                AccessFs::MakeDir,
9172                AccessFs::RemoveFile,
9173                AccessFs::RemoveDir,
9174            ] {
9175                assert!(
9176                    rights.contains(needed),
9177                    "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
9178                     `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
9179                );
9180            }
9181
9182            assert_eq!(
9183                rights.contains(AccessFs::Execute),
9184                workdir_exec,
9185                "/tmp is backed by a directory inside the workdir, so Execute there must follow \
9186                 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
9187                 make /tmp a hole through which capsule-written bytes run",
9188            );
9189        }
9190
9191        assert_eq!(
9192            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9193            linux_enforce::workdir_access_rights(false),
9194            "the /tmp rule and the workdir rule share one right-set binding in \
9195             apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
9196             that binding must be revisited together",
9197        );
9198    }
9199
9200    /// An enforcement on the unconfined tier, holding test session directories, with `declared`
9201    /// as its `capabilities.resources` block.
9202    fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
9203        ShellEnforcement {
9204            tier: EnforcementTier::Unconfined,
9205            declared_resources: declared,
9206            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
9207            proc_view: crate::proc_view::tier_proc_view(EnforcementTier::Unconfined),
9208            ..ShellEnforcement::environment_only()
9209        }
9210        .with_test_session_dirs()
9211    }
9212
9213    /// The `/proc/self/status` line starting `field`, of this process or of what `command`
9214    /// printed.
9215    fn status_line(status: &str, field: &str) -> String {
9216        status
9217            .lines()
9218            .find(|line| line.starts_with(field))
9219            .unwrap_or_else(|| panic!("no {field} line in {status}"))
9220            .to_string()
9221    }
9222
9223    /// What `script` prints when spawned through `prepare_enforcement` on `enforcement`.
9224    fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
9225        let mut command = std::process::Command::new("bash");
9226        command.args(["-c", script]);
9227        let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
9228            .expect("the unconfined tier installs nothing that can fail");
9229        assert!(matches!(supervisor, SupervisorHandle::Noop));
9230        let output = command.output().expect("bash runs");
9231        assert!(output.status.success(), "{output:?}");
9232        String::from_utf8(output.stdout).unwrap()
9233    }
9234
9235    /// A child spawned through `prepare_enforcement` on the unconfined tier keeps this process's
9236    /// `NoNewPrivs`, effective capabilities and network namespace: nothing is installed, so `sudo`
9237    /// works wherever the operator's sudoers allows it.
9238    #[test]
9239    fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
9240        let enforcement = unconfined_enforcement(Default::default());
9241        let child = spawn_unconfined(
9242            &enforcement,
9243            "cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
9244        );
9245        let own = std::fs::read_to_string("/proc/self/status").unwrap();
9246        for field in ["NoNewPrivs:", "CapEff:"] {
9247            assert_eq!(
9248                status_line(&child, field),
9249                status_line(&own, field),
9250                "{child}"
9251            );
9252        }
9253        let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
9254        assert!(
9255            child.contains(&format!("netns={}", own_netns.display())),
9256            "{child}"
9257        );
9258        assert_eq!(
9259            enforcement.session_netns.creations(),
9260            0,
9261            "the unconfined tier creates no network namespace"
9262        );
9263    }
9264
9265    /// The unconfined tier sets no rlimit a manifest does not declare — the child keeps this
9266    /// process's `RLIMIT_NOFILE` and `RLIMIT_CORE` — and sets exactly what one declares.
9267    #[test]
9268    fn an_unconfined_child_gets_only_the_declared_rlimits() {
9269        let read = |resource| {
9270            let limit = rustix::process::getrlimit(resource);
9271            let show = |value: Option<u64>| {
9272                value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
9273            };
9274            format!("{} {}", show(limit.current), show(limit.maximum))
9275        };
9276        let undeclared = spawn_unconfined(
9277            &unconfined_enforcement(Default::default()),
9278            "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9279        );
9280        assert_eq!(
9281            undeclared,
9282            format!(
9283                "{}\n{}\n",
9284                read(rustix::process::Resource::Nofile),
9285                read(rustix::process::Resource::Core)
9286            )
9287        );
9288        let declared = spawn_unconfined(
9289            &unconfined_enforcement(murmur_artifact::ResourceCapabilities {
9290                max_open_files: Some(256),
9291                ..Default::default()
9292            }),
9293            "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9294        );
9295        assert_eq!(
9296            declared,
9297            format!("256 256\n{}\n", read(rustix::process::Resource::Core))
9298        );
9299    }
9300
9301    #[test]
9302    fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
9303        let mut command = std::process::Command::new("true");
9304        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
9305        let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
9306            .expect("environment_only must never fail");
9307        assert!(matches!(supervisor, SupervisorHandle::Noop));
9308        assert_eq!(
9309            enforcement.session_netns.creations(),
9310            0,
9311            "a tier with no kernel sandbox must not create a network namespace"
9312        );
9313    }
9314
9315    // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
9316    // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
9317    // test passes while exercising a path that enforces nothing. The two tests below assert the
9318    // opposite direction — that allowlisted binaries still run — which any tier does evidence.
9319
9320    #[test]
9321    fn kernel_tier_allows_exec_within_shell_allowlist() {
9322        if crate::network_namespace::skip_without_egress_namespace(
9323            "kernel_tier_allows_exec_within_shell_allowlist",
9324        ) {
9325            return;
9326        }
9327        let tier = detect_enforcement_tier();
9328        if tier == EnforcementTier::EnvironmentOnly {
9329            eprintln!(
9330                "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
9331                 EnvironmentOnly on a Linux host — degrading gracefully"
9332            );
9333            return;
9334        }
9335
9336        let temp = tempfile::tempdir().unwrap();
9337        let policy = CapabilityPolicy {
9338            shell_allow: vec!["bash".to_string()],
9339            ..CapabilityPolicy::default()
9340        };
9341        let enforcement = ShellEnforcement {
9342            tier,
9343            network_allow_ips: Vec::new(),
9344            unix_sockets_allowed: policy.unix_sockets_allowed,
9345            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9346                &resolve_exec_allowlist(&policy.shell_allow),
9347            )),
9348            ..host_bounding_base()
9349        };
9350
9351        let result = crate::shell::execute_shell(
9352            "bash",
9353            &["-c", "exit 7"],
9354            &[],
9355            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9356            &policy,
9357            &enforcement,
9358        )
9359        .unwrap();
9360
9361        assert_eq!(
9362            result.exit_code, 7,
9363            "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
9364             proving the allowlist isn't a blanket deny"
9365        );
9366    }
9367
9368    #[test]
9369    fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
9370        if crate::network_namespace::skip_without_egress_namespace(
9371            "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
9372        ) {
9373            return;
9374        }
9375        let tier = detect_enforcement_tier();
9376        if tier == EnforcementTier::EnvironmentOnly {
9377            eprintln!(
9378                "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
9379                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9380            );
9381            return;
9382        }
9383
9384        let temp = tempfile::tempdir().unwrap();
9385        let policy = CapabilityPolicy {
9386            shell_allow: vec!["bash".to_string(), "ls".to_string()],
9387            ..CapabilityPolicy::default()
9388        };
9389        let enforcement = ShellEnforcement {
9390            tier,
9391            network_allow_ips: Vec::new(),
9392            unix_sockets_allowed: policy.unix_sockets_allowed,
9393            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9394                &resolve_exec_allowlist(&policy.shell_allow),
9395            )),
9396            ..host_bounding_base()
9397        };
9398
9399        // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
9400        // bash — the case the no-regression scenario ("allowlisted binary still functions")
9401        // actually cares about.
9402        let result = crate::shell::execute_shell(
9403            "bash",
9404            &["-c", "ls"],
9405            &[],
9406            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9407            &policy,
9408            &enforcement,
9409        )
9410        .unwrap();
9411
9412        assert_eq!(
9413            result.exit_code, 0,
9414            "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
9415             (stderr: {})",
9416            result.stderr
9417        );
9418    }
9419
9420    #[test]
9421    fn kernel_tier_denies_network_connect_outside_allowlist() {
9422        if crate::network_namespace::skip_without_egress_namespace(
9423            "kernel_tier_denies_network_connect_outside_allowlist",
9424        ) {
9425            return;
9426        }
9427        let tier = detect_enforcement_tier();
9428        if tier == EnforcementTier::EnvironmentOnly {
9429            eprintln!(
9430                "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
9431                 EnvironmentOnly on a Linux host — degrading gracefully"
9432            );
9433            return;
9434        }
9435
9436        // A real listener on the *host*, so the destination port is genuinely open. The failure
9437        // below is therefore about reachability, not about a closed port: the subprocess runs in
9438        // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
9439        // listener does not exist at all.
9440        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9441        let port = listener.local_addr().unwrap().port();
9442
9443        let temp = tempfile::tempdir().unwrap();
9444        let policy = CapabilityPolicy {
9445            shell_allow: vec!["bash".to_string()],
9446            ..CapabilityPolicy::default()
9447        };
9448        let enforcement = ShellEnforcement {
9449            tier,
9450            // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
9451            // capsule found its way to the endpoint.
9452            network_allow_rules: Vec::new(),
9453            network_allow_ips: Vec::new(),
9454            unix_sockets_allowed: policy.unix_sockets_allowed,
9455            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9456                &resolve_exec_allowlist(&policy.shell_allow),
9457            )),
9458            ..host_bounding_base()
9459        };
9460
9461        // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
9462        let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
9463        let result = crate::shell::execute_shell(
9464            "bash",
9465            &["-c", &script],
9466            &[],
9467            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9468            &policy,
9469            &enforcement,
9470        )
9471        .unwrap();
9472
9473        assert_ne!(
9474            result.exit_code, 0,
9475            "a direct connect() to a destination outside capabilities.network.allow must fail \
9476             even though the port is really open on the host — the subprocess is in its own \
9477             network namespace with no route to it"
9478        );
9479    }
9480
9481    /// The no-regression counterpart of the test above: enforcement must add denials for
9482    /// out-of-policy destinations without breaking permitted ones.
9483    ///
9484    /// The allowlist has to be declared rather than only pre-resolved, because it decides two
9485    /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
9486    /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
9487    ///
9488    /// Deliberately *not* a test of the security property — a green suite proves nothing about
9489    /// the kernel-level claim, and the real check is the hand-run procedure in
9490    /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
9491    /// asserts only that the permitted path still functions.
9492    #[test]
9493    fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
9494        if crate::network_namespace::skip_without_egress_namespace(
9495            "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
9496        ) {
9497            return;
9498        }
9499        let tier = detect_enforcement_tier();
9500        if tier == EnforcementTier::EnvironmentOnly {
9501            eprintln!(
9502                "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
9503                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9504            );
9505            return;
9506        }
9507
9508        // A real listener on the host, answering one connection, so a success below means bytes
9509        // really crossed the namespace boundary through the proxy rather than merely connecting.
9510        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9511        let port = listener.local_addr().unwrap().port();
9512        let server = std::thread::spawn(move || {
9513            if let Ok((mut stream, _)) = listener.accept() {
9514                use std::io::Write;
9515                let _ = stream.write_all(b"pong\n");
9516            }
9517        });
9518
9519        let temp = tempfile::tempdir().unwrap();
9520        // Naming the port is required, not incidental: it is what puts a listener on that port
9521        // inside the namespace. See `egress_proxy::egress_listen_ports`.
9522        let allow = vec![format!("http://127.0.0.1:{port}")];
9523        let policy = CapabilityPolicy {
9524            shell_allow: vec!["bash".to_string()],
9525            network_allow: allow.clone(),
9526            ..CapabilityPolicy::default()
9527        };
9528        let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
9529        let enforcement = ShellEnforcement {
9530            tier,
9531            egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
9532            network_allow_rules: rules,
9533            network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
9534            unix_sockets_allowed: policy.unix_sockets_allowed,
9535            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9536                &resolve_exec_allowlist(&policy.shell_allow),
9537            )),
9538            ..host_bounding_base()
9539        };
9540
9541        // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
9542        // and this measures the network path alone.
9543        let script =
9544            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
9545        let result = crate::shell::execute_shell(
9546            "bash",
9547            &["-c", &script],
9548            &[],
9549            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9550            &policy,
9551            &enforcement,
9552        )
9553        .unwrap();
9554        let _ = server.join();
9555
9556        assert_eq!(
9557            result.exit_code, 0,
9558            "a destination inside capabilities.network.allow must stay reachable — enforcement \
9559             adds denials for out-of-policy destinations, it does not degrade permitted ones \
9560             (stderr: {})",
9561            result.stderr
9562        );
9563        assert!(
9564            result.stdout.contains("pong"),
9565            "the host listener's bytes must come back through the proxy unmodified \
9566             (stdout: {:?}, stderr: {})",
9567            result.stdout,
9568            result.stderr
9569        );
9570    }
9571
9572    #[test]
9573    fn kernel_full_denies_filesystem_access_outside_workdir() {
9574        if crate::network_namespace::skip_without_egress_namespace(
9575            "kernel_full_denies_filesystem_access_outside_workdir",
9576        ) {
9577            return;
9578        }
9579        let tier = detect_enforcement_tier();
9580        if tier != EnforcementTier::KernelFull {
9581            eprintln!(
9582                "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
9583                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
9584                 instead"
9585            );
9586            return;
9587        }
9588
9589        let temp = tempfile::tempdir().unwrap();
9590        let policy = CapabilityPolicy {
9591            shell_allow: vec!["bash".to_string()],
9592            ..CapabilityPolicy::default()
9593        };
9594        let enforcement = ShellEnforcement {
9595            tier,
9596            network_allow_ips: Vec::new(),
9597            unix_sockets_allowed: policy.unix_sockets_allowed,
9598            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9599                &resolve_exec_allowlist(&policy.shell_allow),
9600            )),
9601            ..host_bounding_base()
9602        };
9603
9604        let result = crate::shell::execute_shell(
9605            "bash",
9606            &["-c", "cat /etc/hostname"],
9607            &[],
9608            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9609            &policy,
9610            &enforcement,
9611        )
9612        .unwrap();
9613
9614        assert_ne!(
9615            result.exit_code, 0,
9616            "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
9617        );
9618    }
9619
9620    // ---- moves, links and truncation under the ruleset ABI ----
9621    //
9622    // The tests below resolve their enforcement the way a session does — `ShellEnforcement::resolve`
9623    // over a live `HostProbe` at `scoped` — so the ruleset ABI they run under is the one the probe
9624    // fills. Each one that needs a newer ABI than this host offers prints a skip line and returns.
9625
9626    /// A `scoped` session's enforcement for `policy`, from a live probe, or `None` (after a skip
9627    /// line naming `test`) when this host cannot install a Landlock domain at `min_abi` or above.
9628    fn scoped_landlock_enforcement(
9629        test: &str,
9630        policy: &CapabilityPolicy,
9631        min_abi: u8,
9632    ) -> Option<ShellEnforcement> {
9633        if crate::network_namespace::skip_without_egress_namespace(test) {
9634            return None;
9635        }
9636        let probe = HostProbe::probe();
9637        let enforcement = ShellEnforcement::resolve_declared(
9638            policy,
9639            murmur_artifact::ContainmentClass::Scoped,
9640            probe.clone(),
9641        )
9642        .unwrap()
9643        .with_test_session_dirs();
9644        if enforcement.tier != EnforcementTier::KernelFull
9645            || enforcement.landlock_abi < Some(min_abi)
9646        {
9647            eprintln!(
9648                "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
9649                 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
9650                enforcement.tier,
9651                enforcement.landlock_abi,
9652                probe.landlock_kernel_abi()
9653            );
9654            return None;
9655        }
9656        Some(enforcement)
9657    }
9658
9659    fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
9660        let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
9661            .iter()
9662            .map(|name| name.to_string())
9663            .collect();
9664        shell_allow.extend(extra.iter().map(|name| name.to_string()));
9665        CapabilityPolicy {
9666            shell_allow,
9667            ..CapabilityPolicy::default()
9668        }
9669    }
9670
9671    fn run_bash_in(
9672        workdir: &Path,
9673        script: &str,
9674        policy: &CapabilityPolicy,
9675        enforcement: &ShellEnforcement,
9676    ) -> crate::shell::ShellResult {
9677        let result = crate::shell::execute_shell(
9678            "bash",
9679            &["-c", script],
9680            &[],
9681            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
9682            policy,
9683            enforcement,
9684        )
9685        .unwrap();
9686        println!(
9687            "exit {}\n--- stdout\n{}--- stderr\n{}---",
9688            result.exit_code, result.stdout, result.stderr
9689        );
9690        result
9691    }
9692
9693    /// A `<base>/work` workdir with a sibling `<base>/outside` on the same filesystem, so a
9694    /// refusal between them is Landlock's and never the VFS's cross-mount `EXDEV`.
9695    fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
9696        let base = tempfile::tempdir().unwrap();
9697        let work = base.path().join("work");
9698        let outside = base.path().join("outside");
9699        std::fs::create_dir(&work).unwrap();
9700        std::fs::create_dir(&outside).unwrap();
9701        (base, work, outside)
9702    }
9703
9704    /// With `Refer` granted on the workdir, `mv` between two of its folders is a real `rename(2)`
9705    /// (the inode survives), and `ln` and a directory reparent across folders both succeed.
9706    #[test]
9707    fn kernel_tier_moves_files_between_workdir_folders() {
9708        let test = "kernel_tier_moves_files_between_workdir_folders";
9709        let policy = coreutils_policy(&[]);
9710        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9711            return;
9712        };
9713        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
9714        assert_eq!(enforcement.landlock_abi, Some(3));
9715
9716        let temp = tempfile::tempdir().unwrap();
9717        let result = run_bash_in(
9718            temp.path(),
9719            r#"set -e
9720mkdir a b
9721echo payload > a/x
9722before=$(stat -c %i a/x)
9723mv a/x b/x
9724after=$(stat -c %i b/x)
9725echo "mv a/x b/x: inode $before -> $after"
9726[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
9727ln b/x a/y
9728[ "$(cat a/y)" = payload ] || exit 11
9729echo "ln b/x a/y: ok"
9730mkdir a/d
9731mv a/d b/d
9732[ -d b/d ] && [ ! -e a/d ] || exit 12
9733echo "mv a/d b/d: ok"
9734"#,
9735            &policy,
9736            &enforcement,
9737        );
9738
9739        assert_eq!(
9740            result.exit_code, 0,
9741            "a move, a hard link and a directory reparent between workdir folders must all \
9742             succeed under Refer"
9743        );
9744    }
9745
9746    /// On a Landlock tier the session's home folder is `$HOME` and carries the work folder's
9747    /// rights: a command writes, renames within and removes files under it. The records directory
9748    /// around it stays read-only: a command cannot write `out/result.txt`, create `children/x`, or
9749    /// move the home folder itself aside. Both directories sit under the host's `/tmp` here, so
9750    /// where `/tmp` is mapped this also proves the home folder is re-exposed writable over the
9751    /// read-only records directory.
9752    #[test]
9753    fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
9754        let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
9755        let policy = coreutils_policy(&["rm", "rmdir"]);
9756        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9757            return;
9758        };
9759        eprintln!(
9760            "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
9761            enforcement.tier,
9762            enforcement.landlock_abi,
9763            HostProbe::probe().landlock_kernel_abi(),
9764            enforcement.installed_temp_mapping()
9765        );
9766        let records = enforcement.records_root().unwrap().clone();
9767        records
9768            .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
9769            .unwrap();
9770        let home = enforcement.home_dir_path().unwrap().to_path_buf();
9771
9772        let workdir = tempfile::tempdir().unwrap();
9773        let result = run_bash_in(
9774            workdir.path(),
9775            &format!(
9776                r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
9777echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
9778  && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
9779if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
9780if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
9781if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
9782"#,
9783                home = home.display()
9784            ),
9785            &policy,
9786            &enforcement,
9787        );
9788
9789        assert_eq!(
9790            result.stdout,
9791            "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
9792            "stderr: {}",
9793            result.stderr
9794        );
9795        assert!(home.is_dir());
9796        assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
9797        assert!(!records.host_path("out/result.txt").exists());
9798        assert!(!records.host_path("children").exists());
9799        assert!(!home.with_extension("moved").exists());
9800        assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
9801    }
9802
9803    /// A delegated child's records, laid out where staging puts them — its records group,
9804    /// `records_group(<records dir>/children)`, beside the session's own directories — are out of
9805    /// reach of a parent's command: it can neither list the child's records directory nor read its
9806    /// result, while the runtime directory reads in the same command. The parent's runtime reads
9807    /// the child's answer and hands it over instead.
9808    ///
9809    /// The refusal is `Permission denied` where Landlock refuses the path, and `No such file or
9810    /// directory` where the command's private `/tmp` does not re-expose it.
9811    #[test]
9812    fn children_records_are_not_reachable_by_a_parents_commands() {
9813        let test = "children_records_are_not_reachable_by_a_parents_commands";
9814        let policy = coreutils_policy(&["ls"]);
9815        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9816            return;
9817        };
9818        let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
9819        std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
9820        let base = tempfile::tempdir().unwrap();
9821        let child_records = base
9822            .path()
9823            .join("records/children/worker-0123456789abcdef/ses_child");
9824        let result_file = child_records.join("out/result.txt");
9825        std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
9826        std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
9827
9828        let workdir = tempfile::tempdir().unwrap();
9829        let result = run_bash_in(
9830            workdir.path(),
9831            &format!(
9832                r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
9833if ls '{dir}'; then echo listed; else echo list-refused; fi
9834if cat '{file}'; then echo read; else echo read-refused; fi
9835"#,
9836                dir = child_records.display(),
9837                file = result_file.display(),
9838            ),
9839            &policy,
9840            &enforcement,
9841        );
9842
9843        assert_eq!(
9844            result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
9845            "stderr: {}",
9846            result.stderr
9847        );
9848        assert!(
9849            !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
9850            "the child's answer reached the parent's command: {result:?}"
9851        );
9852        for line in result.stderr.lines() {
9853            assert!(
9854                line.contains("Permission denied") || line.contains("No such file or directory"),
9855                "an unexpected refusal: {}",
9856                result.stderr
9857            );
9858        }
9859        assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
9860    }
9861
9862    /// The ruleset a 5.13–5.18 kernel gets, forced through the same `landlock_abi` field the probe
9863    /// fills: the capsule still launches, `ln` across folders is `EXDEV`, and `mv` survives only
9864    /// by copying.
9865    #[test]
9866    fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
9867        let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
9868        let policy = coreutils_policy(&[]);
9869        let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9870            return;
9871        };
9872        enforcement.landlock_abi = Some(1);
9873
9874        let temp = tempfile::tempdir().unwrap();
9875        let result = run_bash_in(
9876            temp.path(),
9877            r#"mkdir a b || exit 20
9878echo payload > a/x || exit 21
9879before=$(stat -c %i a/x)
9880mv a/x b/x || exit 22
9881[ -f b/x ] && [ ! -e a/x ] || exit 23
9882after=$(stat -c %i b/x)
9883echo "mv a/x b/x: inode $before -> $after"
9884[ "$before" != "$after" ] || exit 24
9885if ln b/x a/y; then exit 25; fi
9886exit 42
9887"#,
9888            &policy,
9889            &enforcement,
9890        );
9891
9892        assert_eq!(
9893            result.exit_code, 42,
9894            "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
9895             back to copy-and-delete (a new inode) and ln fails"
9896        );
9897        assert!(
9898            result.stderr.contains("Invalid cross-device link"),
9899            "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
9900            result.stderr
9901        );
9902    }
9903
9904    /// Nothing is moved or linked out of the workdir into a sibling folder on the same filesystem.
9905    #[test]
9906    fn kernel_tier_refuses_rename_out_of_the_workdir() {
9907        let test = "kernel_tier_refuses_rename_out_of_the_workdir";
9908        let policy = coreutils_policy(&[]);
9909        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9910            return;
9911        };
9912
9913        let (_base, work, outside) = work_and_outside();
9914        let script = format!(
9915            r#"echo payload > x || exit 20
9916if mv x '{outside}/x'; then exit 21; fi
9917if ln x '{outside}/x2'; then exit 22; fi
9918exit 0
9919"#,
9920            outside = outside.display()
9921        );
9922        let result = run_bash_in(&work, &script, &policy, &enforcement);
9923
9924        assert_eq!(
9925            result.exit_code, 0,
9926            "mv and ln out of the workdir must both fail"
9927        );
9928        assert!(
9929            work.join("x").exists(),
9930            "the source must stay in the workdir"
9931        );
9932        assert!(!outside.join("x").exists(), "nothing may be moved out");
9933        assert!(!outside.join("x2").exists(), "nothing may be linked out");
9934    }
9935
9936    /// Nothing is moved or linked into the workdir from a sibling folder on the same filesystem.
9937    #[test]
9938    fn kernel_tier_refuses_rename_into_the_workdir() {
9939        let test = "kernel_tier_refuses_rename_into_the_workdir";
9940        let policy = coreutils_policy(&[]);
9941        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9942            return;
9943        };
9944
9945        let (_base, work, outside) = work_and_outside();
9946        std::fs::write(outside.join("y"), "original\n").unwrap();
9947        let script = format!(
9948            r#"if mv '{outside}/y' ./y; then exit 21; fi
9949if ln '{outside}/y' ./y2; then exit 22; fi
9950exit 0
9951"#,
9952            outside = outside.display()
9953        );
9954        let result = run_bash_in(&work, &script, &policy, &enforcement);
9955
9956        assert_eq!(
9957            result.exit_code, 0,
9958            "mv and ln into the workdir must both fail"
9959        );
9960        assert_eq!(
9961            std::fs::read_to_string(outside.join("y")).unwrap(),
9962            "original\n",
9963            "the outside file must be untouched"
9964        );
9965        assert!(!work.join("y").exists(), "nothing may be moved in");
9966        assert!(!work.join("y2").exists(), "nothing may be linked in");
9967    }
9968
9969    /// A by-path `truncate(2)` on a host file outside the workdir: refused once the ruleset
9970    /// handles `Truncate` (ABI v3), and not mediated at all at ABI v1 — the gap v3 closes.
9971    #[test]
9972    fn kernel_tier_truncate_outside_the_workdir_is_refused() {
9973        use std::os::unix::fs::PermissionsExt;
9974
9975        let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
9976        let policy = coreutils_policy(&["perl"]);
9977        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9978            return;
9979        };
9980
9981        let (_base, work, outside) = work_and_outside();
9982        let target = outside.join("t");
9983        std::fs::write(&target, "keep\n").unwrap();
9984        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
9985        let script = format!(
9986            r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
9987            target.display()
9988        );
9989
9990        let refused = run_bash_in(&work, &script, &policy, &enforcement);
9991        assert_ne!(
9992            refused.exit_code, 0,
9993            "truncate(2) outside the workdir must be refused at ABI 3"
9994        );
9995        assert!(
9996            refused.stderr.contains("Permission denied"),
9997            "the refusal must be EACCES; stderr: {}",
9998            refused.stderr
9999        );
10000        assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
10001
10002        let mut at_v1 = enforcement.clone();
10003        at_v1.landlock_abi = Some(1);
10004        let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
10005        assert_eq!(
10006            unmediated.exit_code, 0,
10007            "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
10008        );
10009        assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
10010    }
10011
10012    /// Handling `Truncate` must not refuse what a capsule truncates legitimately: its own files,
10013    /// with `truncate` and with a `>` redirect over an existing file, and `/dev/null`.
10014    #[test]
10015    fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
10016        let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
10017        let policy = coreutils_policy(&["truncate"]);
10018        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10019            return;
10020        };
10021
10022        let temp = tempfile::tempdir().unwrap();
10023        let result = run_bash_in(
10024            temp.path(),
10025            r#"set -e
10026echo hello > f && truncate -s 0 f && [ ! -s f ]
10027echo again > f && echo over > f && [ "$(cat f)" = over ]
10028: > g
10029echo discard > /dev/null
10030echo ok
10031"#,
10032            &policy,
10033            &enforcement,
10034        );
10035
10036        assert_eq!(
10037            result.exit_code, 0,
10038            "truncating inside the workdir and writing to /dev/null must keep working"
10039        );
10040    }
10041
10042    /// `uv venv` then `uv pip install <wheel>` in the workdir, offline: uv unpacks the wheel into
10043    /// a temporary folder in its cache and renames it into place. Passes at the probed ABI, and
10044    /// fails with `EXDEV` at ABI v1, so the pass is `Refer`'s.
10045    ///
10046    /// Ignored because it needs two host-side inputs a stock host does not have:
10047    /// `MURMUR_TEST_UV`, an absolute path to a `uv` binary, and `MURMUR_TEST_WHEEL`, an absolute
10048    /// path to `six-1.16.0-py2.py3-none-any.whl`, whose module and version the script checks after
10049    /// installing it. Run without them it
10050    /// panics naming the missing variable. `uv`, `python3` and its stdlib are granted by this
10051    /// test's policy only.
10052    #[test]
10053    #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
10054    fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
10055        let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
10056        let input = |name: &str| {
10057            let value = std::env::var(name)
10058                .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
10059            let path = PathBuf::from(value);
10060            assert!(
10061                path.is_absolute() && path.is_file(),
10062                "{name}={} is not a file",
10063                path.display()
10064            );
10065            path
10066        };
10067        let uv = input("MURMUR_TEST_UV");
10068        let wheel = input("MURMUR_TEST_WHEEL");
10069        let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
10070        let stdlib = std::process::Command::new(&python)
10071            .args([
10072                "-I",
10073                "-c",
10074                "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
10075            ])
10076            .output()
10077            .expect("host python3 runs");
10078        let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
10079
10080        let policy = CapabilityPolicy {
10081            shell_allow: vec![
10082                "bash".to_string(),
10083                uv.display().to_string(),
10084                python.display().to_string(),
10085            ],
10086            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10087                binary: "python3".to_string(),
10088                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10089                    path: stdlib,
10090                    list_dir: true,
10091                }],
10092            }],
10093            ..CapabilityPolicy::default()
10094        };
10095        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10096            return;
10097        };
10098
10099        let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
10100        let script = format!(
10101            r#"set -e
10102export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
10103'{uv}' venv --python '{python}' .venv
10104'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
10105.venv/bin/python -c 'import six; print(six.__version__)'
10106"#,
10107            uv = uv.display(),
10108            python = python.display(),
10109        );
10110        let install = |enforcement: &ShellEnforcement| {
10111            let temp = tempfile::tempdir().unwrap();
10112            std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
10113            run_bash_in(temp.path(), &script, &policy, enforcement)
10114        };
10115
10116        let at_probed = install(&enforcement);
10117        assert_eq!(
10118            at_probed.exit_code, 0,
10119            "uv must install the wheel at ABI {:?}",
10120            enforcement.landlock_abi
10121        );
10122        assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
10123
10124        let mut at_v1 = enforcement.clone();
10125        at_v1.landlock_abi = Some(1);
10126        let refused = install(&at_v1);
10127        let output = format!("{}{}", refused.stdout, refused.stderr);
10128        assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
10129        assert!(
10130            output.contains("os error 18") || output.contains("Invalid cross-device link"),
10131            "at ABI v1 the install must fail with EXDEV: {output}"
10132        );
10133    }
10134
10135    // The two tests below exercise the derived Landlock grants. A green run is not evidence they
10136    // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
10137    // no Landlock domain is installed at all. Both tests print a skip line and return when the
10138    // host is not `KernelFull`.
10139
10140    #[test]
10141    fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
10142    {
10143        if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
10144            return;
10145        }
10146        let tier = detect_enforcement_tier();
10147        if tier != EnforcementTier::KernelFull {
10148            eprintln!(
10149                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10150                 kernel_full_runs_nontrivial_shell_allowlist_... requires \
10151                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10152                 This run does NOT execute the derived-grant Landlock code path at all, so a \
10153                 green result here is not evidence the workdir-scope exec bug is fixed — only a \
10154                 real KernelFull Linux host can prove that."
10155            );
10156            return;
10157        }
10158
10159        let temp = tempfile::tempdir().unwrap();
10160        // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
10161        // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
10162        // Linux hosts and both live outside the workdir, so this only succeeds if the derived
10163        // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
10164        let policy = CapabilityPolicy {
10165            shell_allow: vec!["bash".to_string(), "cat".to_string()],
10166            ..CapabilityPolicy::default()
10167        };
10168        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10169        let enforcement = ShellEnforcement {
10170            tier,
10171            network_allow_ips: Vec::new(),
10172            unix_sockets_allowed: policy.unix_sockets_allowed,
10173            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10174                &exec_allow_paths,
10175            )),
10176            ..host_bounding_base()
10177        };
10178
10179        // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
10180        // nested, dynamically-linked exec — the case the whole fix exists for.
10181        let result = crate::shell::execute_shell(
10182            "bash",
10183            &["-c", "echo landlock-grant-ok | cat"],
10184            &[],
10185            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10186            &policy,
10187            &enforcement,
10188        )
10189        .expect("execute_shell should return Ok, not Err");
10190
10191        assert_eq!(
10192            result.exit_code, 0,
10193            "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
10194             must let each exec and dynamic-link outside the workdir (stderr: {})",
10195            result.stderr
10196        );
10197        assert!(
10198            result.stdout.contains("landlock-grant-ok"),
10199            "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
10200            result.stdout,
10201            result.stderr
10202        );
10203    }
10204
10205    #[test]
10206    fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
10207        if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
10208            return;
10209        }
10210        let tier = detect_enforcement_tier();
10211        if tier != EnforcementTier::KernelFull {
10212            eprintln!(
10213                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10214                 kernel_full_denies_write_outside_workdir_... requires \
10215                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10216                 This run does NOT execute the Landlock code path, so a green result here is not \
10217                 evidence writes outside the workdir are actually denied — only a real KernelFull \
10218                 Linux host can prove that."
10219            );
10220            return;
10221        }
10222
10223        let workdir = tempfile::tempdir().unwrap();
10224        // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
10225        // reason a write can fail is the Landlock scope — not a missing directory.
10226        let outside = tempfile::tempdir().unwrap();
10227        let target = outside.path().join("should-not-be-written.txt");
10228
10229        let policy = CapabilityPolicy {
10230            shell_allow: vec!["bash".to_string()],
10231            ..CapabilityPolicy::default()
10232        };
10233        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10234        let enforcement = ShellEnforcement {
10235            tier,
10236            network_allow_ips: Vec::new(),
10237            unix_sockets_allowed: policy.unix_sockets_allowed,
10238            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10239                &exec_allow_paths,
10240            )),
10241            ..host_bounding_base()
10242        };
10243
10244        // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
10245        // write itself is what Landlock must refuse. The derived read+execute grants never widen
10246        // to write, so nothing outside the workdir (grant paths included) is writable.
10247        let script = format!("echo pwned > '{}'", target.display());
10248        let result = crate::shell::execute_shell(
10249            "bash",
10250            &["-c", &script],
10251            &[],
10252            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10253            &policy,
10254            &enforcement,
10255        )
10256        .expect("execute_shell should return Ok with a nonzero exit code, not Err");
10257
10258        assert!(
10259            !target.exists(),
10260            "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
10261        );
10262        assert_ne!(
10263            result.exit_code, 0,
10264            "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
10265        );
10266    }
10267
10268    /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
10269    /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
10270    /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
10271    fn kernel_full_enforcement(
10272        tier: EnforcementTier,
10273        policy: &CapabilityPolicy,
10274    ) -> ShellEnforcement {
10275        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10276        let mut landlock_grants =
10277            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
10278        landlock_grants.extend(resolve_interpreter_runtime_grants(
10279            &policy.shell_interpreter_runtime,
10280        ));
10281        ShellEnforcement {
10282            tier,
10283            network_allow_ips: Vec::new(),
10284            unix_sockets_allowed: policy.unix_sockets_allowed,
10285            landlock_grants,
10286            ..host_bounding_base()
10287        }
10288    }
10289
10290    /// A real sealed run: the composed root's `/tmp` is the session's temporary folder in its
10291    /// records directory, `$HOME` and the synthetic `/etc/passwd`'s home are both its home folder,
10292    /// writable inside the root (`cd ~ && touch f`), the work folder gains nothing, and a delegated
10293    /// child's result beside the session's directories is not in the root at all.
10294    ///
10295    /// This drives a composed root end to end, so it needs a host that can actually reach
10296    /// `KernelSealed` and skips loudly on one that cannot; the parent-side halves are covered
10297    /// unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports the same host
10298    /// answer this checks.
10299    #[test]
10300    fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
10301        if crate::network_namespace::skip_without_egress_namespace(
10302            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
10303        ) {
10304            return;
10305        }
10306        let probe = HostProbe::probe();
10307        if probe.tier() != EnforcementTier::KernelSealed {
10308            eprintln!(
10309                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
10310                 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
10311                 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
10312                 {:?}. No composed root is built in this run.",
10313                probe.tier()
10314            );
10315            return;
10316        }
10317
10318        let accessible = tempfile::tempdir().unwrap();
10319        let policy = CapabilityPolicy {
10320            shell_allow: vec![
10321                "bash".to_string(),
10322                "cat".to_string(),
10323                "id".to_string(),
10324                "touch".to_string(),
10325                "getent".to_string(),
10326                "cut".to_string(),
10327            ],
10328            ..CapabilityPolicy::default()
10329        };
10330        let runtime = tempfile::tempdir().unwrap();
10331        let enforcement = ShellEnforcement::resolve_declared(
10332            &policy,
10333            murmur_artifact::ContainmentClass::Sealed,
10334            probe.clone(),
10335        )
10336        .expect("a sealed-capable host resolves sealed enforcement")
10337        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
10338        // A delegated child's result, beside the session's own directories as a child that has
10339        // staged leaves it, and out of the composed root's reach.
10340        let child_result = runtime
10341            .path()
10342            .join("children/worker-0123456789abcdef/out/result.txt");
10343        std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
10344        std::fs::write(&child_result, "the child's answer").unwrap();
10345        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
10346        eprintln!(
10347            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
10348             ruleset ABI {:?} (kernel {:?})",
10349            enforcement.tier,
10350            enforcement.landlock_abi,
10351            probe.landlock_kernel_abi()
10352        );
10353        let probe_name = format!("sealed-probe-{}", std::process::id());
10354
10355        let result = crate::shell::execute_shell(
10356            "bash",
10357            &[
10358                "-c",
10359                &format!(
10360                    "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
10361                     printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
10362                     getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
10363                     {{ cat '{child}' 2>&1 || true; }}",
10364                    child = child_result.display()
10365                ),
10366            ],
10367            &[],
10368            &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
10369            &policy,
10370            &enforcement,
10371        )
10372        .expect("execute_shell returns Ok even when the command itself fails");
10373
10374        assert_eq!(
10375            result.exit_code, 0,
10376            "stdout: {} stderr: {}",
10377            result.stdout, result.stderr
10378        );
10379        let mut lines = result.stdout.lines();
10380        assert_eq!(
10381            lines.next(),
10382            Some("t"),
10383            "the capsule's /tmp must be writable"
10384        );
10385        assert!(
10386            lines.next().is_some_and(|name| !name.is_empty()),
10387            "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
10388             {}",
10389            result.stdout
10390        );
10391        assert_eq!(
10392            lines.next(),
10393            Some("/tmp"),
10394            "TMPDIR names /tmp inside the root"
10395        );
10396        let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
10397        assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
10398        assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
10399        assert_eq!(
10400            lines.next(),
10401            Some("touched"),
10402            "~ is writable inside the root"
10403        );
10404        let child_read = lines.next().unwrap_or_default();
10405        assert!(
10406            child_read.contains("No such file or directory"),
10407            "a child's result is not in the composed root: {}",
10408            result.stdout
10409        );
10410        assert!(
10411            !result.stdout.contains("the child's answer"),
10412            "{}",
10413            result.stdout
10414        );
10415        assert!(Path::new(home).join("f").is_file());
10416
10417        let folder = enforcement.temp_dir_path().unwrap();
10418        assert_eq!(
10419            std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
10420            "t\n",
10421            "the bytes written to /tmp land in the session's temporary folder"
10422        );
10423        assert!(
10424            !Path::new("/tmp").join(&probe_name).exists(),
10425            "nothing reaches the host's /tmp"
10426        );
10427        assert!(runtime
10428            .path()
10429            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
10430            .join("passwd")
10431            .is_file());
10432        assert_eq!(
10433            std::fs::read_dir(accessible.path()).unwrap().count(),
10434            0,
10435            "enabling sealed must add nothing to the work folder"
10436        );
10437    }
10438
10439    // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
10440    // return when the host is not `KernelFull`.
10441
10442    #[test]
10443    fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
10444        if crate::network_namespace::skip_without_egress_namespace(
10445            "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
10446        ) {
10447            return;
10448        }
10449        let tier = detect_enforcement_tier();
10450        if tier != EnforcementTier::KernelFull {
10451            eprintln!(
10452                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10453                 kernel_full_interpreter_runtime_list_dir_false_... requires \
10454                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10455                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10456                 result here is not evidence list_dir:false behaves correctly — only a real \
10457                 KernelFull Linux host can prove that."
10458            );
10459            return;
10460        }
10461
10462        let workdir = tempfile::tempdir().unwrap();
10463        // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
10464        let stdlib = tempfile::tempdir().unwrap();
10465        let module = stdlib.path().join("mod.py");
10466        std::fs::write(&module, "x = 1\n").unwrap();
10467
10468        let policy = CapabilityPolicy {
10469            shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
10470            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10471                binary: "bash".to_string(),
10472                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10473                    path: stdlib.path().to_string_lossy().into_owned(),
10474                    list_dir: false,
10475                }],
10476            }],
10477            ..CapabilityPolicy::default()
10478        };
10479        let enforcement = kernel_full_enforcement(tier, &policy);
10480
10481        // Opening the file by its exact known name must succeed — Landlock's read rights apply to
10482        // the subtree beneath the granted directory.
10483        let read = crate::shell::execute_shell(
10484            "bash",
10485            &["-c", &format!("cat '{}'", module.display())],
10486            &[],
10487            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10488            &policy,
10489            &enforcement,
10490        )
10491        .expect("execute_shell should return Ok");
10492        assert_eq!(
10493            read.exit_code, 0,
10494            "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
10495            read.stderr
10496        );
10497        assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
10498
10499        // Listing the directory's own entries must fail — ReadDir was not granted.
10500        let list = crate::shell::execute_shell(
10501            "bash",
10502            &["-c", &format!("ls '{}'", stdlib.path().display())],
10503            &[],
10504            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10505            &policy,
10506            &enforcement,
10507        )
10508        .expect("execute_shell should return Ok with a nonzero exit code");
10509        assert_ne!(
10510            list.exit_code, 0,
10511            "list_dir:false must deny enumerating the granted directory's own entries"
10512        );
10513    }
10514
10515    #[test]
10516    fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
10517        if crate::network_namespace::skip_without_egress_namespace(
10518            "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
10519        ) {
10520            return;
10521        }
10522        let tier = detect_enforcement_tier();
10523        if tier != EnforcementTier::KernelFull {
10524            eprintln!(
10525                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10526                 kernel_full_interpreter_runtime_list_dir_true_... requires \
10527                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10528                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10529                 result here is not evidence list_dir:true (and its non-widening to the parent) \
10530                 behaves correctly — only a real KernelFull Linux host can prove that."
10531            );
10532            return;
10533        }
10534
10535        let workdir = tempfile::tempdir().unwrap();
10536        // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
10537        let parent = tempfile::tempdir().unwrap();
10538        let stdlib = parent.path().join("stdlib");
10539        std::fs::create_dir(&stdlib).unwrap();
10540        std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
10541
10542        let policy = CapabilityPolicy {
10543            shell_allow: vec!["bash".to_string(), "ls".to_string()],
10544            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10545                binary: "bash".to_string(),
10546                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10547                    path: stdlib.to_string_lossy().into_owned(),
10548                    list_dir: true,
10549                }],
10550            }],
10551            ..CapabilityPolicy::default()
10552        };
10553        let enforcement = kernel_full_enforcement(tier, &policy);
10554
10555        // Listing the granted directory must succeed.
10556        let list = crate::shell::execute_shell(
10557            "bash",
10558            &["-c", &format!("ls '{}'", stdlib.display())],
10559            &[],
10560            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10561            &policy,
10562            &enforcement,
10563        )
10564        .expect("execute_shell should return Ok");
10565        assert_eq!(
10566            list.exit_code, 0,
10567            "list_dir:true must permit enumerating the granted directory (stderr: {})",
10568            list.stderr
10569        );
10570        assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
10571
10572        // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
10573        // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
10574        let list_parent = crate::shell::execute_shell(
10575            "bash",
10576            &["-c", &format!("ls '{}'", parent.path().display())],
10577            &[],
10578            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10579            &policy,
10580            &enforcement,
10581        )
10582        .expect("execute_shell should return Ok with a nonzero exit code");
10583        assert_ne!(
10584            list_parent.exit_code, 0,
10585            "a grant on stdlib/ must not make its (ungranted) parent enumerable"
10586        );
10587    }
10588
10589    /// The `executable` axis, asserted against a real Landlock kernel.
10590    ///
10591    /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
10592    /// readable and enumerable without becoming a place to run programs from. It is asserted here
10593    /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
10594    /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
10595    /// would have to be driven through a composed root, which no unit test can reach.
10596    ///
10597    /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
10598    /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
10599    #[test]
10600    fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
10601        if crate::network_namespace::skip_without_egress_namespace(
10602            "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
10603        ) {
10604            return;
10605        }
10606        let host_tier = detect_enforcement_tier();
10607        if !matches!(
10608            host_tier,
10609            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10610        ) {
10611            eprintln!(
10612                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10613                 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
10614                 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
10615                 result is not evidence that withholding Execute denies execve."
10616            );
10617            return;
10618        }
10619
10620        let workdir = tempfile::tempdir().unwrap();
10621        // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
10622        let tree = tempfile::tempdir().unwrap();
10623        std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
10624        let runme = tree.path().join("runme");
10625        std::fs::copy("/bin/echo", &runme).unwrap();
10626
10627        let policy = CapabilityPolicy {
10628            shell_allow: vec!["bash".to_string(), "ls".to_string()],
10629            ..CapabilityPolicy::default()
10630        };
10631        let with_tree_grant = |executable: bool| {
10632            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
10633            enforcement.landlock_grants.push(LandlockGrant {
10634                path: tree.path().to_path_buf(),
10635                list_dir: true,
10636                executable,
10637            });
10638            enforcement
10639        };
10640        let run = |enforcement: &ShellEnforcement, command: String| {
10641            crate::shell::execute_shell(
10642                "bash",
10643                &["-c", &command],
10644                &[],
10645                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10646                &policy,
10647                enforcement,
10648            )
10649            .expect("execute_shell should return Ok even when the command itself fails")
10650        };
10651
10652        let denied = with_tree_grant(false);
10653        let list = run(&denied, format!("ls '{}'", tree.path().display()));
10654        assert_eq!(
10655            list.exit_code, 0,
10656            "a non-executable grant must still be enumerable — that is its entire purpose \
10657             (stderr: {})",
10658            list.stderr
10659        );
10660        assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
10661
10662        let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
10663        assert_ne!(
10664            exec_denied.exit_code, 0,
10665            "withholding Execute must deny execve inside the granted tree; a program ran instead \
10666             (stdout: {})",
10667            exec_denied.stdout
10668        );
10669        assert!(
10670            !exec_denied.stdout.contains("ran"),
10671            "the binary produced output, so it executed: {}",
10672            exec_denied.stdout
10673        );
10674
10675        // The control: the identical grant with `executable: true` does run it. Without this the
10676        // assertion above could be passing for an unrelated reason (a bad path, a missing library).
10677        let allowed = with_tree_grant(true);
10678        let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
10679        assert_eq!(
10680            exec_allowed.exit_code, 0,
10681            "the same tree granted Execute must run (stderr: {})",
10682            exec_allowed.stderr
10683        );
10684        assert!(
10685            exec_allowed.stdout.contains("ran"),
10686            "stdout: {}",
10687            exec_allowed.stdout
10688        );
10689    }
10690
10691    /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
10692    /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
10693    ///
10694    /// Two kernel facts are pinned here. First, `ReadFile` alone —
10695    /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
10696    /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
10697    /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
10698    /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
10699    /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
10700    /// read, not die in `pre_exec`.
10701    ///
10702    /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
10703    #[test]
10704    fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
10705        if crate::network_namespace::skip_without_egress_namespace(
10706            "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
10707        ) {
10708            return;
10709        }
10710        let host_tier = detect_enforcement_tier();
10711        if !matches!(
10712            host_tier,
10713            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10714        ) {
10715            eprintln!(
10716                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10717                 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
10718                 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
10719                 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
10720                 a file is narrowed instead of refusing the launch."
10721            );
10722            return;
10723        }
10724
10725        let workdir = tempfile::tempdir().unwrap();
10726        // A trust-store stand-in: a regular file outside the workdir, granted on its own.
10727        let store = tempfile::tempdir().unwrap();
10728        let bundle = store.path().join("ca-certificates.crt");
10729        std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
10730
10731        let policy = CapabilityPolicy {
10732            shell_allow: vec!["bash".to_string(), "cat".to_string()],
10733            ..CapabilityPolicy::default()
10734        };
10735        let with_file_grant = |list_dir: bool| {
10736            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
10737            enforcement.landlock_grants.push(LandlockGrant {
10738                path: bundle.clone(),
10739                list_dir,
10740                executable: false,
10741            });
10742            enforcement
10743        };
10744        let read = |enforcement: &ShellEnforcement| {
10745            crate::shell::execute_shell(
10746                "bash",
10747                &["-c", &format!("cat '{}'", bundle.display())],
10748                &[],
10749                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10750                &policy,
10751                enforcement,
10752            )
10753            .expect("execute_shell should return Ok even when the command itself fails")
10754        };
10755
10756        let honest = read(&with_file_grant(false));
10757        assert_eq!(
10758            honest.exit_code, 0,
10759            "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
10760             file entries rely on (stderr: {})",
10761            honest.stderr
10762        );
10763        assert!(
10764            honest.stdout.contains("BEGIN CERTIFICATE"),
10765            "stdout: {}",
10766            honest.stdout
10767        );
10768
10769        let overclaimed = read(&with_file_grant(true));
10770        assert_eq!(
10771            overclaimed.exit_code, 0,
10772            "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
10773             into an EINVAL that refuses the launch (stderr: {})",
10774            overclaimed.stderr
10775        );
10776        assert!(
10777            overclaimed.stdout.contains("BEGIN CERTIFICATE"),
10778            "stdout: {}",
10779            overclaimed.stdout
10780        );
10781    }
10782
10783    // ---- every distinct pre_exec setup failure is legible + fail-closed ----
10784    //
10785    // These force each of the three distinct sandbox setup failures and assert each produces its
10786    // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
10787    // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
10788    // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
10789    // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
10790    // short-circuits before any real Landlock syscall.
10791
10792    struct ForceNoNewPrivsFailureGuard;
10793
10794    impl ForceNoNewPrivsFailureGuard {
10795        fn new() -> Self {
10796            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
10797            Self
10798        }
10799    }
10800
10801    impl Drop for ForceNoNewPrivsFailureGuard {
10802        fn drop(&mut self) {
10803            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
10804        }
10805    }
10806
10807    struct ForceLandlockFailureGuard;
10808
10809    impl ForceLandlockFailureGuard {
10810        fn new() -> Self {
10811            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
10812            Self
10813        }
10814    }
10815
10816    impl Drop for ForceLandlockFailureGuard {
10817        fn drop(&mut self) {
10818            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
10819        }
10820    }
10821
10822    /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
10823    /// depend on any specific grant — they force the failure directly — so this exercises the
10824    /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
10825    fn kernel_full_empty_grants() -> ShellEnforcement {
10826        ShellEnforcement {
10827            tier: EnforcementTier::KernelFull,
10828            network_allow_ips: Vec::new(),
10829            unix_sockets_allowed: false,
10830            landlock_grants: Vec::new(),
10831            ..host_bounding_base()
10832        }
10833    }
10834
10835    /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
10836    /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
10837    /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
10838    fn workdir_resolution_error() -> String {
10839        let temp = tempfile::tempdir().unwrap();
10840        let missing = temp.path().join("does-not-exist-workdir");
10841        let marker = temp.path().join("never-spawned.marker");
10842        let mut command = std::process::Command::new("bash");
10843        command
10844            .arg("-c")
10845            .arg(format!("echo ran > '{}'", marker.display()));
10846
10847        let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
10848            .expect_err("an unresolvable workdir must fail before fork()");
10849        assert!(
10850            !marker.exists(),
10851            "prepare_enforcement must never spawn a subprocess: {error}"
10852        );
10853        error
10854    }
10855
10856    /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
10857    /// child-side failure active, asserts the target script never ran (marker absent), and returns
10858    /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
10859    fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
10860        child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
10861    }
10862
10863    /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
10864    /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
10865    /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
10866    /// identity out to the CLI instead of arriving as one more error string.
10867    fn child_setup_failure_typed<G>(
10868        enforcement: &ShellEnforcement,
10869        arm: impl FnOnce() -> G,
10870    ) -> crate::shell::ShellExecError {
10871        let temp = tempfile::tempdir().unwrap();
10872        let policy = CapabilityPolicy {
10873            shell_allow: vec!["bash".to_string()],
10874            ..CapabilityPolicy::default()
10875        };
10876        let marker = temp.path().join("spawned-anyway.marker");
10877        let script = format!("echo ran > '{}'", marker.display());
10878
10879        let _guard = arm();
10880        let error = crate::shell::execute_shell(
10881            "bash",
10882            &["-c", &script],
10883            &[],
10884            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10885            &policy,
10886            enforcement,
10887        )
10888        .expect_err("a forced child-side setup failure must make execute_shell return Err");
10889        assert!(
10890            !marker.exists(),
10891            "no subprocess may run when pre_exec setup fails: {error}"
10892        );
10893        error
10894    }
10895
10896    #[test]
10897    fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
10898        let error = workdir_resolution_error();
10899        assert!(
10900            error.contains("workdir") && error.contains("does-not-exist-workdir"),
10901            "the error must name the workdir path that failed to resolve: {error}"
10902        );
10903        assert!(
10904            !error.contains("os error 22"),
10905            "must not collapse to the bare EINVAL string: {error}"
10906        );
10907    }
10908
10909    #[test]
10910    fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
10911        if crate::network_namespace::skip_without_egress_namespace(
10912            "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
10913        ) {
10914            return;
10915        }
10916        let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
10917        assert!(
10918            error.contains("no_new_privs"),
10919            "the error must name the no_new_privs step specifically: {error}"
10920        );
10921        assert!(
10922            !error.contains("os error 22"),
10923            "must not collapse to the bare EINVAL string: {error}"
10924        );
10925    }
10926
10927    #[test]
10928    fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
10929        if crate::network_namespace::skip_without_egress_namespace(
10930            "pre_exec_landlock_failure_is_distinct_and_fails_closed",
10931        ) {
10932            return;
10933        }
10934        let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
10935        assert!(
10936            error.contains("landlock"),
10937            "the error must name the Landlock construction step specifically: {error}"
10938        );
10939        assert!(
10940            !error.contains("os error 22"),
10941            "must not collapse to the bare EINVAL string: {error}"
10942        );
10943    }
10944
10945    #[test]
10946    fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
10947        if crate::network_namespace::skip_without_egress_namespace(
10948            "pre_exec_setup_failures_produce_pairwise_distinct_messages",
10949        ) {
10950            return;
10951        }
10952        let workdir_msg = workdir_resolution_error();
10953        let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
10954        let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
10955
10956        let messages = [
10957            ("workdir", &workdir_msg),
10958            ("no_new_privs", &no_new_privs_msg),
10959            ("landlock", &landlock_msg),
10960        ];
10961        for (i, (left_name, left)) in messages.iter().enumerate() {
10962            for (right_name, right) in &messages[i + 1..] {
10963                assert_ne!(left, right, "{left_name} vs {right_name} must differ");
10964            }
10965        }
10966
10967        for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
10968            assert!(!message.is_empty(), "a distinct message must not be empty");
10969            assert!(
10970                !message.contains("os error 22"),
10971                "no failure may read as a bare EINVAL: {message}"
10972            );
10973        }
10974    }
10975
10976    /// The first half of the composed-root failure path: a `pre_exec`
10977    /// composed-root failure comes back out of `execute_shell` as the *typed*
10978    /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
10979    /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
10980    /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
10981    #[test]
10982    fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
10983        if crate::network_namespace::skip_without_egress_namespace(
10984            "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
10985        ) {
10986            return;
10987        }
10988        let error =
10989            child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
10990
10991        assert!(
10992            matches!(
10993                error,
10994                crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
10995            ),
10996            "a sealed-root diagnostic must survive as its own variant, not collapse into \
10997             ShellExecError::Failed: {error}"
10998        );
10999        let fatal = error
11000            .session_fatal()
11001            .expect("a composed-root failure must end the session, not just the tool call");
11002        assert!(
11003            matches!(
11004                fatal,
11005                crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
11006            ),
11007            "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
11008        );
11009        let rendered = error.to_string();
11010        assert!(
11011            rendered.contains("composed root") && rendered.contains("unshare"),
11012            "the message must name both the mechanism and the step that failed: {rendered}"
11013        );
11014        assert!(
11015            !rendered.contains("os error 22"),
11016            "must not collapse to the bare EINVAL string: {rendered}"
11017        );
11018    }
11019
11020    /// The contrast that makes the assertion above mean something: an equally fatal-looking
11021    /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
11022    /// in this path ends a session for a Landlock or `no_new_privs` problem.
11023    #[test]
11024    fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
11025        let landlock =
11026            child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
11027        assert!(
11028            landlock.session_fatal().is_none(),
11029            "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
11030        );
11031
11032        let no_new_privs = child_setup_failure_typed(
11033            &kernel_full_empty_grants(),
11034            ForceNoNewPrivsFailureGuard::new,
11035        );
11036        assert!(
11037            no_new_privs.session_fatal().is_none(),
11038            "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
11039        );
11040    }
11041
11042    // ---- one network namespace per session -------------------------------------------------
11043    //
11044    // Every test below drives real commands through `execute_shell`/`run_shell` against an
11045    // enforcement resolved the way `stage_session` resolves one, and reads what a later command of
11046    // the same session — or of another session — can reach.
11047
11048    /// `bash`, `nc` and `sleep`, plus `extra`: what a model starting and probing a server uses.
11049    fn server_policy(extra: &[&str]) -> CapabilityPolicy {
11050        CapabilityPolicy {
11051            shell_allow: ["bash", "nc", "sleep"]
11052                .iter()
11053                .chain(extra)
11054                .map(|binary| binary.to_string())
11055                .collect(),
11056            ..CapabilityPolicy::default()
11057        }
11058    }
11059
11060    /// A session's enforcement, resolved as `stage_session` resolves a `scoped` capsule's.
11061    fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
11062        let enforcement = ShellEnforcement::resolve_declared(
11063            policy,
11064            murmur_artifact::ContainmentClass::Scoped,
11065            HostProbe::probe(),
11066        )
11067        .expect("the policy resolves")
11068        .with_test_session_dirs();
11069        assert_eq!(
11070            enforcement.tier,
11071            EnforcementTier::KernelFull,
11072            "a scoped capsule on this host runs on KernelFull"
11073        );
11074        enforcement
11075    }
11076
11077    /// A one-shot server on `127.0.0.1:<port>` that answers its one connection with `reply`,
11078    /// backgrounded with its output redirected so the command that starts it returns at once.
11079    fn one_shot_server(reply: &str, port: u16) -> String {
11080        format!(
11081            "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
11082        )
11083    }
11084
11085    /// Connects to `127.0.0.1:<port>`, retrying for up to five seconds while the server comes up,
11086    /// and prints the first line it reads.
11087    fn retrying_client(port: u16) -> String {
11088        format!(
11089            "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
11090             sleep 0.1; done; read -r reply <&3; echo \"$reply\""
11091        )
11092    }
11093
11094    fn bash(
11095        script: &str,
11096        workdir: &Path,
11097        policy: &CapabilityPolicy,
11098        enforcement: &ShellEnforcement,
11099    ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
11100        crate::shell::execute_shell(
11101            "bash",
11102            &["-c", script],
11103            &[],
11104            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
11105            policy,
11106            enforcement,
11107        )
11108    }
11109
11110    fn pid_in(workdir: &Path, file: &str) -> i32 {
11111        std::fs::read_to_string(workdir.join(file))
11112            .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
11113            .trim()
11114            .parse()
11115            .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
11116    }
11117
11118    fn process_exists(pid: i32) -> bool {
11119        Path::new(&format!("/proc/{pid}")).exists()
11120    }
11121
11122    /// Whether `/proc/<pid>` is gone within `timeout`. A killed process stays visible until its
11123    /// parent — `init` for an orphan — reaps it, which takes a moment after the kill itself.
11124    fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
11125        let deadline = std::time::Instant::now() + timeout;
11126        while process_exists(pid) {
11127            if std::time::Instant::now() >= deadline {
11128                return false;
11129            }
11130            std::thread::sleep(std::time::Duration::from_millis(20));
11131        }
11132        true
11133    }
11134
11135    /// A server one command backgrounds answers a later, separate command of the same session.
11136    #[test]
11137    #[allow(clippy::print_stdout)]
11138    fn a_listener_started_by_one_command_answers_the_next() {
11139        if crate::network_namespace::skip_without_egress_namespace(
11140            "a_listener_started_by_one_command_answers_the_next",
11141        ) {
11142            return;
11143        }
11144        let temp = tempfile::tempdir().unwrap();
11145        let policy = server_policy(&[]);
11146        let enforcement = session_enforcement(&policy);
11147
11148        let started = std::time::Instant::now();
11149        let server = bash(
11150            &one_shot_server("pong", 8080),
11151            temp.path(),
11152            &policy,
11153            &enforcement,
11154        )
11155        .expect("the server command runs");
11156        let server_ms = started.elapsed().as_millis();
11157        assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11158        assert!(
11159            started.elapsed() < std::time::Duration::from_secs(5),
11160            "a backgrounded server with its output redirected must not hold the command open"
11161        );
11162
11163        let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
11164            .expect("the client command runs");
11165        println!(
11166            "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
11167             namespaces created: {}",
11168            enforcement.tier,
11169            server.exit_code,
11170            client.exit_code,
11171            client.stdout.trim(),
11172            enforcement.session_netns.creations()
11173        );
11174        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11175        assert_eq!(client.stdout.trim(), "pong");
11176        assert_eq!(enforcement.session_netns.creations(), 1);
11177
11178        enforcement.end_session();
11179    }
11180
11181    /// A foreground server demoted after its grace period keeps listening, and a later command
11182    /// reaches it; the demoted command then completes with what it received.
11183    #[test]
11184    #[allow(clippy::print_stdout)]
11185    fn a_demoted_server_stays_reachable_from_later_commands() {
11186        use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
11187
11188        if crate::network_namespace::skip_without_egress_namespace(
11189            "a_demoted_server_stays_reachable_from_later_commands",
11190        ) {
11191            return;
11192        }
11193        let temp = tempfile::tempdir().unwrap();
11194        let base = tempfile::tempdir().unwrap();
11195        let policy = server_policy(&[]);
11196        let enforcement =
11197            session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
11198        let (registry, mut reports) = DetachedRegistry::new();
11199
11200        let outcome = crate::shell::run_shell(
11201            "nc",
11202            &["-l", "127.0.0.1", "8080"],
11203            &[],
11204            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11205            &policy,
11206            &enforcement,
11207            Some(DetachPolicy {
11208                grace: Some(std::time::Duration::ZERO),
11209                delivered: true,
11210                registry: Arc::clone(&registry),
11211                command: "-l 127.0.0.1 8080".to_string(),
11212                context_id: "ctx_demoted_server".to_string(),
11213                provenance: None,
11214                abandoned: None,
11215                canceled: None,
11216            }),
11217        )
11218        .expect("the server command runs");
11219        let crate::shell::ShellOutcome::Detached(info) = outcome else {
11220            panic!("a zero grace period demotes the server, got {outcome:?}");
11221        };
11222
11223        let client = bash(
11224            "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
11225             sleep 0.1; done; echo ping >&3",
11226            temp.path(),
11227            &policy,
11228            &enforcement,
11229        )
11230        .expect("the client command runs");
11231        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11232
11233        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
11234        let completion = loop {
11235            match reports.try_recv() {
11236                Ok(DetachedReport::Completed(completion)) => break completion,
11237                Ok(DetachedReport::Lost(_)) => {}
11238                Err(_) if std::time::Instant::now() < deadline => {
11239                    std::thread::sleep(std::time::Duration::from_millis(20));
11240                }
11241                Err(error) => panic!("the demoted server never completed: {error}"),
11242            }
11243        };
11244        let log = std::fs::read_to_string(&completion.output_path)
11245            .expect("the demoted command's log is written");
11246        println!(
11247            "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
11248            info.work_id,
11249            client.exit_code,
11250            completion.work_id,
11251            completion.exit_code,
11252            log.contains("ping")
11253        );
11254        assert_eq!(completion.work_id, info.work_id);
11255        assert_eq!(completion.exit_code, 0, "log: {log}");
11256        assert!(log.contains("ping"), "log: {log}");
11257
11258        enforcement.end_session();
11259    }
11260
11261    /// A truncated command's log, named by its absolute path in the session's records directory,
11262    /// reads back under the session's own enforcement: through the shell interpreter and through
11263    /// a binary allowed on its own that gets the path as an argument. A write to it is refused,
11264    /// on any tier that installs Landlock.
11265    #[test]
11266    #[allow(clippy::print_stdout)]
11267    fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
11268        // A host that cannot give the session its network namespace refuses the session at
11269        // staging, so there is no session enforcement to run under.
11270        if crate::network_namespace::skip_without_egress_namespace(
11271            "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
11272        ) {
11273            return;
11274        }
11275        let base = home_tempdir();
11276        let workdir = home_tempdir();
11277        let policy = CapabilityPolicy {
11278            shell_allow: vec!["bash".to_string(), "grep".to_string()],
11279            ..CapabilityPolicy::default()
11280        };
11281        let enforcement = ShellEnforcement::resolve_declared(
11282            &policy,
11283            murmur_artifact::ContainmentClass::Scoped,
11284            HostProbe::probe(),
11285        )
11286        .expect("the policy resolves")
11287        .with_runtime_dir(test_runtime_dir(base.path()), None);
11288        let landlock = matches!(
11289            enforcement.tier,
11290            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11291        );
11292        println!(
11293            "tier {:?}; landlock ABI {:?}",
11294            enforcement.tier,
11295            detect_landlock_abi()
11296        );
11297
11298        // A builtin loop: only `bash` and `grep` are executable under Landlock.
11299        let printed = run_bash_in(
11300            workdir.path(),
11301            "for ((i = 1; i <= 20000; i++)); do echo $i; done",
11302            &policy,
11303            &enforcement,
11304        );
11305        assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
11306        assert!(printed.truncated);
11307        let path = printed
11308            .full_output_path
11309            .clone()
11310            .expect("a truncated command names its log");
11311        assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
11312        let before = std::fs::read(&path).unwrap();
11313
11314        let through_bash = run_bash_in(
11315            workdir.path(),
11316            &format!("grep -x 20000 {path}"),
11317            &policy,
11318            &enforcement,
11319        );
11320        assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
11321        assert_eq!(through_bash.stdout, "20000\n");
11322
11323        let through_grep = crate::shell::execute_shell(
11324            "grep",
11325            &["-x", "20000", &path],
11326            &[],
11327            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11328            &policy,
11329            &enforcement,
11330        )
11331        .unwrap();
11332        assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
11333        assert_eq!(through_grep.stdout, "20000\n");
11334
11335        if landlock {
11336            let write = run_bash_in(
11337                workdir.path(),
11338                &format!("echo x >> {path}"),
11339                &policy,
11340                &enforcement,
11341            );
11342            assert_ne!(write.exit_code, 0, "a command appended to its own log");
11343            assert_eq!(std::fs::read(&path).unwrap(), before);
11344        } else {
11345            println!(
11346                "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
11347                 the write half needs Landlock; detected {:?}",
11348                enforcement.tier
11349            );
11350        }
11351        assert!(!workdir.path().join("logs").exists());
11352        enforcement.end_session();
11353    }
11354
11355    /// Two sessions each bind the same port on their own loopback, each reaches only its own
11356    /// server, and the host reaches neither.
11357    #[test]
11358    #[allow(clippy::print_stdout)]
11359    fn two_sessions_do_not_share_loopback() {
11360        if crate::network_namespace::skip_without_egress_namespace(
11361            "two_sessions_do_not_share_loopback",
11362        ) {
11363            return;
11364        }
11365        let port = {
11366            let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11367            probe.local_addr().unwrap().port()
11368        };
11369        let policy = server_policy(&[]);
11370        let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
11371        let session_a = session_enforcement(&policy);
11372        let session_b = session_enforcement(&policy);
11373
11374        for (dir, session, reply) in [
11375            (&dir_a, &session_a, "pong-a"),
11376            (&dir_b, &session_b, "pong-b"),
11377        ] {
11378            let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
11379                .expect("the server command runs");
11380            assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11381        }
11382        // Both servers bound the same port: each is still running, waiting for its one client.
11383        std::thread::sleep(std::time::Duration::from_millis(300));
11384        // Each pid file holds a pid in its session's PID namespace, where the two servers may
11385        // well share a number: both must be live host processes. Each is looked up in its own
11386        // session's namespace only, since tests running beside this one hold sessions whose
11387        // processes can carry the same number.
11388        let server_in = |dir: &tempfile::TempDir, session: &ShellEnforcement| -> Option<i32> {
11389            let join = session
11390                .session_netns
11391                .establish(
11392                    &session.egress_tcp_ports,
11393                    crate::egress_proxy::EgressPolicy::new(
11394                        session.network_allow_rules.clone(),
11395                        session.network_allow_ips.clone(),
11396                    ),
11397                )
11398                .expect("the session's namespace is established");
11399            let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
11400                .expect("the session's PID namespace handle is readable");
11401            let namespace_pid = pid_in(dir.path(), "server.pid").to_string();
11402            std::fs::read_dir("/proc")
11403                .expect("/proc is readable")
11404                .filter_map(Result::ok)
11405                .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
11406                .find(|pid| {
11407                    std::fs::read_link(format!("/proc/{pid}/ns/pid"))
11408                        .is_ok_and(|namespace| namespace == pid_ns)
11409                        && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
11410                            |status| {
11411                                status
11412                                    .lines()
11413                                    .find_map(|line| line.strip_prefix("NSpid:"))
11414                                    .and_then(|fields| fields.split_whitespace().last())
11415                                    == Some(namespace_pid.as_str())
11416                            },
11417                        )
11418                })
11419        };
11420        let servers: std::collections::HashSet<i32> = [(&dir_a, &session_a), (&dir_b, &session_b)]
11421            .into_iter()
11422            .filter_map(|(dir, session)| server_in(dir, session))
11423            .collect();
11424        assert_eq!(
11425            servers.len(),
11426            2,
11427            "a server's bind on {port} failed: {servers:?}"
11428        );
11429
11430        let host = std::net::TcpStream::connect(("127.0.0.1", port));
11431        let host_error = host.expect_err("the host must not reach a session's loopback server");
11432        assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
11433
11434        let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
11435            .expect("B's client runs");
11436        assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
11437
11438        // B's one-shot server is spent; A's still listens on the same port in A's namespace.
11439        let second_b = bash(
11440            &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
11441            dir_b.path(),
11442            &policy,
11443            &session_b,
11444        )
11445        .expect("B's second client runs");
11446
11447        let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
11448            .expect("A's client runs");
11449        println!(
11450            "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
11451             stderr {:?}; A read {:?}",
11452            client_b.stdout.trim(),
11453            second_b.exit_code,
11454            second_b.stderr.trim(),
11455            client_a.stdout.trim()
11456        );
11457        assert_eq!(client_b.stdout.trim(), "pong-b");
11458        assert_ne!(second_b.exit_code, 0);
11459        assert!(
11460            second_b.stderr.contains("Connection refused"),
11461            "stderr: {}",
11462            second_b.stderr
11463        );
11464        assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
11465        assert_eq!(client_a.stdout.trim(), "pong-a");
11466
11467        session_a.end_session();
11468        session_b.end_session();
11469    }
11470
11471    /// The session's one egress proxy serves every command, refuses what is not allowlisted,
11472    /// and is created once.
11473    #[test]
11474    #[allow(clippy::print_stdout)]
11475    fn the_session_proxy_serves_every_command() {
11476        if crate::network_namespace::skip_without_egress_namespace(
11477            "the_session_proxy_serves_every_command",
11478        ) {
11479            return;
11480        }
11481        // Answers every connection, so the first and third commands both get a reply.
11482        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11483        let port = listener.local_addr().unwrap().port();
11484        std::thread::spawn(move || {
11485            for stream in listener.incoming() {
11486                use std::io::Write;
11487                if let Ok(mut stream) = stream {
11488                    let _ = stream.write_all(b"pong\n");
11489                }
11490            }
11491        });
11492
11493        let temp = tempfile::tempdir().unwrap();
11494        let policy = CapabilityPolicy {
11495            network_allow: vec![format!("http://127.0.0.1:{port}")],
11496            ..server_policy(&[])
11497        };
11498        let enforcement = session_enforcement(&policy);
11499        let through_proxy =
11500            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
11501
11502        let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11503        let unlisted = bash(
11504            "exec 3<>/dev/tcp/10.255.255.1/9",
11505            temp.path(),
11506            &policy,
11507            &enforcement,
11508        )
11509        .unwrap();
11510        let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11511        println!(
11512            "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
11513             created: {}",
11514            first.stdout.trim(),
11515            first.exit_code,
11516            unlisted.exit_code,
11517            unlisted.stderr.trim(),
11518            third.stdout.trim(),
11519            third.exit_code,
11520            enforcement.session_netns.creations()
11521        );
11522        assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
11523        assert_eq!(first.stdout.trim(), "pong");
11524        assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
11525        assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
11526        assert_eq!(third.stdout.trim(), "pong");
11527        assert_eq!(enforcement.session_netns.creations(), 1);
11528
11529        enforcement.end_session();
11530    }
11531
11532    /// Commands whose first spawns race each other still create exactly one namespace, and
11533    /// all of them land in it.
11534    #[test]
11535    #[allow(clippy::print_stdout)]
11536    fn concurrent_first_commands_share_one_namespace() {
11537        if crate::network_namespace::skip_without_egress_namespace(
11538            "concurrent_first_commands_share_one_namespace",
11539        ) {
11540            return;
11541        }
11542        let temp = tempfile::tempdir().unwrap();
11543        let policy = server_policy(&[]);
11544        let enforcement = session_enforcement(&policy);
11545        // One one-shot server per client, on its own port: a second client queued on a one-shot
11546        // `nc -l` would sit in its listen backlog and be closed unanswered when it exits.
11547        let servers = (8081..=8083)
11548            .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
11549            .collect::<Vec<_>>()
11550            .join("; ");
11551        let scripts = [
11552            servers,
11553            retrying_client(8081),
11554            retrying_client(8082),
11555            retrying_client(8083),
11556        ];
11557
11558        let barrier = std::sync::Barrier::new(scripts.len());
11559        let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
11560            let handles: Vec<_> = scripts
11561                .iter()
11562                .map(|script| {
11563                    let enforcement = enforcement.clone();
11564                    let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
11565                    scope.spawn(move || {
11566                        barrier.wait();
11567                        bash(script, workdir, policy, &enforcement).expect("the command runs")
11568                    })
11569                })
11570                .collect();
11571            handles
11572                .into_iter()
11573                .map(|handle| handle.join().unwrap())
11574                .collect()
11575        });
11576
11577        for (index, result) in results.iter().enumerate() {
11578            println!(
11579                "command {index}: exit {} stdout {:?}",
11580                result.exit_code,
11581                result.stdout.trim()
11582            );
11583            assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
11584        }
11585        for client in &results[1..] {
11586            assert_eq!(client.stdout.trim(), "pong");
11587        }
11588        println!(
11589            "namespaces created: {}",
11590            enforcement.session_netns.creations()
11591        );
11592        assert_eq!(enforcement.session_netns.creations(), 1);
11593
11594        enforcement.end_session();
11595    }
11596
11597    /// Ending the session kills everything it started — a `setsid` process and a background
11598    /// server included — releases the namespace, and refuses every command after.
11599    #[test]
11600    #[allow(clippy::print_stdout)]
11601    fn ending_the_session_kills_everything_it_started() {
11602        if crate::cgroup::skip_without_host_support(
11603            "ending_the_session_kills_everything_it_started",
11604        ) {
11605            return;
11606        }
11607        let temp = tempfile::tempdir().unwrap();
11608        let policy = server_policy(&["setsid"]);
11609        let scope =
11610            crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
11611                .expect("a delegated scope")
11612                .scope
11613                .expect("a required scope on Linux is never None");
11614        let enforcement =
11615            session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
11616
11617        let started = bash(
11618            "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
11619             nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
11620            temp.path(),
11621            &policy,
11622            &enforcement,
11623        )
11624        .expect("the command runs");
11625        assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
11626        // The pid files hold pids in the session's PID namespace.
11627        let sleeper = host_pid(pid_in(temp.path(), "sleeper.pid"));
11628        let server = host_pid(pid_in(temp.path(), "server.pid"));
11629        assert!(process_exists(sleeper), "the setsid sleeper is running");
11630        assert!(process_exists(server), "the background server is running");
11631
11632        let ending = std::time::Instant::now();
11633        enforcement.end_session();
11634        let took = ending.elapsed();
11635
11636        let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
11637        let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
11638        let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
11639        let after = bash("true", temp.path(), &policy, &enforcement);
11640        println!(
11641            "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
11642             gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
11643            took.as_millis(),
11644            procs.trim(),
11645            after.as_ref().map(|result| result.exit_code)
11646        );
11647        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
11648        assert!(sleeper_gone, "the setsid sleeper outlived its session");
11649        assert!(server_gone, "the background server outlived its session");
11650        assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
11651        let refusal = after
11652            .expect_err("a command after the session ended must be refused")
11653            .to_string();
11654        assert!(refusal.contains("session has ended"), "{refusal}");
11655    }
11656
11657    // ---- the session's temporary folder ----
11658    //
11659    // Each test below resolves its enforcement the way a session does, over a live `HostProbe`,
11660    // and maps `/tmp` with the answer staging would give. One that needs a private mount
11661    // namespace prints a `[SKIP-HOST]` line and returns on a host that cannot give one.
11662
11663    /// A directory under `HOME`, outside `/tmp`, removed when the value drops.
11664    fn home_tempdir() -> tempfile::TempDir {
11665        tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
11666    }
11667
11668    /// A session's enforcement at `class` from a live probe, holding a runtime directory, records
11669    /// directory and temporary folder under `base`, with the `/tmp` mapping staging decides for
11670    /// `workdir`; `None` after a skip line naming `test` when that mapping is not
11671    /// [`crate::temp_dir::TempMapping::MountNamespace`].
11672    fn mapped_enforcement(
11673        test: &str,
11674        policy: &CapabilityPolicy,
11675        class: murmur_artifact::ContainmentClass,
11676        base: &Path,
11677        workdir: &Path,
11678    ) -> Option<ShellEnforcement> {
11679        if crate::network_namespace::skip_without_egress_namespace(test) {
11680            return None;
11681        }
11682        let probe = HostProbe::probe();
11683        let mount_namespace_usable = probe.mount_namespace_usable();
11684        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
11685            .unwrap()
11686            .with_runtime_dir(test_runtime_dir(base), None);
11687        let mapping =
11688            crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
11689        if mapping != crate::temp_dir::TempMapping::MountNamespace {
11690            crate::runtime_err!(
11691                "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
11692                 {:?})",
11693                enforcement.tier
11694            );
11695            return None;
11696        }
11697        Some(enforcement.with_temp_mapping(mapping))
11698    }
11699
11700    /// The same session enforcement with the mount namespace probed as unusable: `KernelFull`,
11701    /// `/tmp` not mapped. `None` after a skip line on a host that has no `KernelFull` at all.
11702    fn unmapped_kernel_full_enforcement(
11703        test: &str,
11704        policy: &CapabilityPolicy,
11705        base: &Path,
11706        workdir: &Path,
11707    ) -> Option<ShellEnforcement> {
11708        if crate::network_namespace::skip_without_egress_namespace(test) {
11709            return None;
11710        }
11711        let live = HostProbe::probe();
11712        if !matches!(
11713            live.tier(),
11714            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11715        ) {
11716            crate::runtime_err!(
11717                "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
11718                live.tier()
11719            );
11720            return None;
11721        }
11722        let refused = crate::namespace_probe::MountAttempt::Failed(
11723            crate::namespace_probe::MountAttemptError::Step {
11724                step: crate::namespace_probe::MountStep::UnshareMount,
11725                errno: libc::EPERM,
11726            },
11727        );
11728        let probe = HostProbe::from_parts(
11729            Some(true),
11730            crate::namespace_probe::NamespaceReading {
11731                mount: refused.clone(),
11732                sealed_root: refused,
11733                ..live.namespaces().clone()
11734            },
11735        )
11736        .with_landlock_kernel_abi(live.landlock_kernel_abi());
11737        assert!(!probe.mount_namespace_usable());
11738        let enforcement = ShellEnforcement::resolve_declared(
11739            policy,
11740            murmur_artifact::ContainmentClass::Scoped,
11741            probe,
11742        )
11743        .unwrap()
11744        .with_runtime_dir(test_runtime_dir(base), None);
11745        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
11746        let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
11747        assert_eq!(
11748            mapping,
11749            crate::temp_dir::TempMapping::NotMapped(
11750                crate::temp_dir::NotMappedReason::NoMountNamespace
11751            )
11752        );
11753        Some(enforcement.with_temp_mapping(mapping))
11754    }
11755
11756    /// A name no other test or run uses beneath `/tmp`.
11757    fn unique_tmp_name(label: &str) -> String {
11758        static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
11759        format!(
11760            "murmur-probe-{label}-{}-{}",
11761            std::process::id(),
11762            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
11763        )
11764    }
11765
11766    /// The parent-side spec re-exposes the work folder and the home folder read-write and the
11767    /// runtime and records directories and every grant path read-only, each at its exact path
11768    /// beneath `/tmp`, the home folder over the records directory it lies in.
11769    #[test]
11770    fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
11771        let base = tempfile::tempdir().unwrap();
11772        let workdir = tempfile::tempdir().unwrap();
11773        let grant_dir = tempfile::tempdir().unwrap();
11774        let grant = grant_dir.path().join("bin");
11775        std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
11776        let mut enforcement = ShellEnforcement {
11777            tier: EnforcementTier::KernelFull,
11778            ..ShellEnforcement::environment_only().with_test_session_dirs()
11779        }
11780        .with_runtime_dir(test_runtime_dir(base.path()), None)
11781        .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
11782        enforcement.landlock_grants =
11783            LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
11784        let folder = enforcement
11785            .runtime_dir
11786            .as_ref()
11787            .and_then(|roots| roots.temp.as_ref())
11788            .unwrap()
11789            .path_fd()
11790            .unwrap();
11791
11792        let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
11793
11794        let canonical = |path: &Path| {
11795            std::fs::canonicalize(path)
11796                .unwrap()
11797                .to_string_lossy()
11798                .into_owned()
11799        };
11800        let mut expected = vec![
11801            (canonical(workdir.path()), false),
11802            (
11803                canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
11804                true,
11805            ),
11806            (
11807                canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
11808                true,
11809            ),
11810            (
11811                canonical(
11812                    &base
11813                        .path()
11814                        .join(crate::records_dir::RECORDS_DIR_NAME)
11815                        .join(crate::records_dir::HOME_SUBDIR),
11816                ),
11817                false,
11818            ),
11819            (canonical(&grant), true),
11820        ];
11821        expected.sort();
11822        assert_eq!(spec.reexposed_sources(), expected);
11823    }
11824
11825    /// At `scoped` and at `advisory`, a literal `/tmp` path lands in the session's temporary
11826    /// folder, `TMPDIR` is `/tmp`, and the host's `/tmp` gains nothing.
11827    #[test]
11828    fn a_literal_tmp_path_reaches_the_session_temp_folder() {
11829        const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
11830        for class in [
11831            murmur_artifact::ContainmentClass::Scoped,
11832            murmur_artifact::ContainmentClass::Advisory,
11833        ] {
11834            let base = home_tempdir();
11835            let workdir = home_tempdir();
11836            let policy = coreutils_policy(&[]);
11837            let Some(enforcement) =
11838                mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
11839            else {
11840                return;
11841            };
11842            let name = unique_tmp_name("literal");
11843            let result = run_bash_in(
11844                workdir.path(),
11845                &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
11846                &policy,
11847                &enforcement,
11848            );
11849            assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
11850            assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
11851            let folder = enforcement.temp_dir_path().unwrap();
11852            assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
11853            assert!(
11854                !Path::new("/tmp").join(&name).exists(),
11855                "{class:?}: the host's /tmp gained {name}"
11856            );
11857        }
11858    }
11859
11860    /// `KernelSeccompOnly` maps `/tmp` through the same per-command mount namespace: the session
11861    /// enforcement with its tier set to seccomp-only, so no Landlock domain is installed.
11862    #[test]
11863    fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
11864        const TEST: &str =
11865            "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
11866        let base = home_tempdir();
11867        let workdir = home_tempdir();
11868        let policy = coreutils_policy(&[]);
11869        let Some(enforcement) = mapped_enforcement(
11870            TEST,
11871            &policy,
11872            murmur_artifact::ContainmentClass::Scoped,
11873            base.path(),
11874            workdir.path(),
11875        ) else {
11876            return;
11877        };
11878        let enforcement = ShellEnforcement {
11879            tier: EnforcementTier::KernelSeccompOnly,
11880            ..enforcement
11881        };
11882        assert_eq!(
11883            enforcement.installed_temp_mapping(),
11884            crate::temp_dir::TempMapping::MountNamespace
11885        );
11886        let name = unique_tmp_name("seccomp-only");
11887        let result = run_bash_in(
11888            workdir.path(),
11889            &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
11890            &policy,
11891            &enforcement,
11892        );
11893        assert_eq!(result.exit_code, 0, "{}", result.stderr);
11894        assert_eq!(result.stdout, "x\n/tmp\n");
11895        let folder = enforcement.temp_dir_path().unwrap();
11896        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
11897        assert!(
11898            !Path::new("/tmp").join(&name).exists(),
11899            "the host's /tmp gained {name}"
11900        );
11901    }
11902
11903    /// Two sessions' commands each see their own folder at `/tmp`.
11904    #[test]
11905    fn two_sessions_do_not_share_tmp() {
11906        const TEST: &str = "two_sessions_do_not_share_tmp";
11907        let policy = coreutils_policy(&[]);
11908        let (base_a, base_b) = (home_tempdir(), home_tempdir());
11909        let (work_a, work_b) = (home_tempdir(), home_tempdir());
11910        let class = murmur_artifact::ContainmentClass::Scoped;
11911        let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
11912            return;
11913        };
11914        let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
11915            return;
11916        };
11917        let name = unique_tmp_name("shared");
11918
11919        let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
11920        assert_eq!(first.exit_code, 0, "{}", first.stderr);
11921        let second = run_bash_in(
11922            work_b.path(),
11923            &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
11924            &policy,
11925            &b,
11926        );
11927        assert_eq!(second.exit_code, 0, "{}", second.stderr);
11928        assert_eq!(second.stdout, "b\n");
11929        let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
11930        assert_eq!(again.stdout, "a\n", "{}", again.stderr);
11931        assert_ne!(a.temp_dir_path(), b.temp_dir_path());
11932        assert!(!Path::new("/tmp").join(&name).exists());
11933    }
11934
11935    /// With the workdir, the runtime and records directories and `HOME` all beneath the host's
11936    /// `/tmp`, every one of them is still reached at its own path while `/tmp` is the folder.
11937    #[test]
11938    fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
11939        const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
11940        let base = tempfile::tempdir().unwrap();
11941        let workdir = tempfile::tempdir().unwrap();
11942        assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
11943        let policy = coreutils_policy(&[]);
11944        let Some(enforcement) = mapped_enforcement(
11945            TEST,
11946            &policy,
11947            murmur_artifact::ContainmentClass::Scoped,
11948            base.path(),
11949            workdir.path(),
11950        ) else {
11951            return;
11952        };
11953        std::fs::write(
11954            enforcement.runtime_dir_path().unwrap().join("probe"),
11955            b"runtime\n",
11956        )
11957        .unwrap();
11958        let name = unique_tmp_name("under");
11959        let result = run_bash_in(
11960            workdir.path(),
11961            &format!(
11962                "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
11963                 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
11964                 cat /tmp/{name}"
11965            ),
11966            &policy,
11967            &enforcement,
11968        );
11969        assert_eq!(result.exit_code, 0, "{}", result.stderr);
11970        assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
11971        assert_eq!(
11972            std::fs::read_to_string(workdir.path().join("f")).unwrap(),
11973            "a\n"
11974        );
11975
11976        // Only the paths the command needs are re-exposed, and only the workdir writably: a
11977        // sibling of the records directory is absent, and the records directory refuses a write
11978        // although it now lies beneath the folder's writable rule.
11979        let sibling = base.path().join("sibling");
11980        std::fs::write(&sibling, b"host\n").unwrap();
11981        let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
11982        std::fs::write(&record, b"{}\n").unwrap();
11983        let refused = run_bash_in(
11984            workdir.path(),
11985            &format!(
11986                "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
11987                sibling = sibling.display()
11988            ),
11989            &policy,
11990            &enforcement,
11991        );
11992        assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
11993        assert_ne!(refused.exit_code, 0);
11994        assert!(
11995            refused.stderr.contains("Read-only file system"),
11996            "{}",
11997            refused.stderr
11998        );
11999        assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
12000        let folder = enforcement.temp_dir_path().unwrap();
12001        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
12002        assert!(!Path::new("/tmp").join(&name).exists());
12003    }
12004
12005    /// Without a usable mount namespace, `KernelFull` maps nothing: a literal `/tmp` write is
12006    /// refused by Landlock, and `$TMPDIR` is the folder's absolute path and works.
12007    #[test]
12008    fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
12009        const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
12010        let base = home_tempdir();
12011        let workdir = home_tempdir();
12012        let policy = coreutils_policy(&[]);
12013        let Some(enforcement) =
12014            unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12015        else {
12016            return;
12017        };
12018        let name = unique_tmp_name("refused");
12019        let refused = run_bash_in(
12020            workdir.path(),
12021            &format!("echo y > /tmp/{name}"),
12022            &policy,
12023            &enforcement,
12024        );
12025        assert_ne!(refused.exit_code, 0);
12026        assert!(
12027            refused.stderr.contains("Permission denied"),
12028            "{}",
12029            refused.stderr
12030        );
12031        assert!(!Path::new("/tmp").join(&name).exists());
12032
12033        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12034        let works = run_bash_in(
12035            workdir.path(),
12036            "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
12037             \"$TEMP\"",
12038            &policy,
12039            &enforcement,
12040        );
12041        assert_eq!(works.exit_code, 0, "{}", works.stderr);
12042        let folder_text = folder.to_string_lossy();
12043        assert_eq!(
12044            works.stdout,
12045            format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
12046        );
12047        assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
12048    }
12049
12050    /// The records directory stays read-only apart from `tmp/`: no record can be linked, moved
12051    /// or appended into the folder, and the folder itself cannot be renamed — mapped and not.
12052    #[test]
12053    fn a_record_cannot_be_moved_or_linked_into_tmp() {
12054        const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
12055        let policy = coreutils_policy(&[]);
12056        for mapped in [true, false] {
12057            let base = home_tempdir();
12058            let workdir = home_tempdir();
12059            let enforcement = if mapped {
12060                mapped_enforcement(
12061                    TEST,
12062                    &policy,
12063                    murmur_artifact::ContainmentClass::Scoped,
12064                    base.path(),
12065                    workdir.path(),
12066                )
12067            } else {
12068                unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12069            };
12070            let Some(enforcement) = enforcement else {
12071                return;
12072            };
12073            let records = enforcement.records_dir_path().unwrap().to_path_buf();
12074            let record = records.join("trace.jsonl");
12075            std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
12076            let before = std::fs::read(&record).unwrap();
12077            for script in [
12078                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
12079                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
12080                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
12081                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
12082                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
12083                "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
12084                "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
12085            ] {
12086                let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
12087                assert_ne!(
12088                    result.exit_code, 0,
12089                    "mapped: {mapped}: `{script}` succeeded"
12090                );
12091            }
12092            assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
12093            assert!(records
12094                .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
12095                .is_dir());
12096            assert!(!records.join("tmp-moved").exists());
12097        }
12098    }
12099
12100    /// The folder carries the workdir's right-set, `Execute` axis included: a binary copied into
12101    /// `/tmp` runs only with `workdir_exec: true`.
12102    #[test]
12103    fn tmp_follows_workdir_exec() {
12104        const TEST: &str = "tmp_follows_workdir_exec";
12105        for (workdir_exec, expected) in [(false, 126), (true, 0)] {
12106            let base = home_tempdir();
12107            let workdir = home_tempdir();
12108            let policy = CapabilityPolicy {
12109                workdir_exec_allowed: workdir_exec,
12110                ..coreutils_policy(&["cp", "true"])
12111            };
12112            let Some(enforcement) = mapped_enforcement(
12113                TEST,
12114                &policy,
12115                murmur_artifact::ContainmentClass::Advisory,
12116                base.path(),
12117                workdir.path(),
12118            ) else {
12119                return;
12120            };
12121            let result = run_bash_in(
12122                workdir.path(),
12123                "cp /bin/true /tmp/t && /tmp/t",
12124                &policy,
12125                &enforcement,
12126            );
12127            assert_eq!(
12128                result.exit_code, expected,
12129                "workdir_exec: {workdir_exec}: {}",
12130                result.stderr
12131            );
12132            if !workdir_exec {
12133                assert!(
12134                    result.stderr.contains("Permission denied"),
12135                    "{}",
12136                    result.stderr
12137                );
12138            }
12139        }
12140    }
12141
12142    /// A mapping that cannot be built fails the spawn with its own error and runs nothing.
12143    #[test]
12144    fn a_failed_tmp_mapping_spawns_nothing() {
12145        const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
12146        let base = home_tempdir();
12147        let workdir = home_tempdir();
12148        let policy = coreutils_policy(&[]);
12149        let Some(enforcement) = mapped_enforcement(
12150            TEST,
12151            &policy,
12152            murmur_artifact::ContainmentClass::Scoped,
12153            base.path(),
12154            workdir.path(),
12155        ) else {
12156            return;
12157        };
12158        let error = {
12159            let _forced = ForceTmpMapFailureGuard::new();
12160            crate::shell::execute_shell(
12161                "bash",
12162                &["-c", "echo ran > \"$(pwd)/marker\""],
12163                &[],
12164                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12165                &policy,
12166                &enforcement,
12167            )
12168            .expect_err("a failed mapping fails the spawn")
12169        };
12170        let message = error.to_string();
12171        println!("{message}");
12172        assert!(
12173            message.contains("could not map /tmp onto this session's temporary folder"),
12174            "{message}"
12175        );
12176        assert!(
12177            matches!(error, crate::shell::ShellExecError::Failed(_)),
12178            "an ordinary spawn failure, not a session-fatal one: {error:?}"
12179        );
12180        assert!(!workdir.path().join("marker").exists(), "the command ran");
12181    }
12182
12183    /// A link swapped onto the folder's path after the session holds it redirects no bind: the
12184    /// child's open reaches another directory, and the spawn fails naming the step.
12185    #[test]
12186    fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
12187        const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
12188        let base = home_tempdir();
12189        let workdir = home_tempdir();
12190        let policy = coreutils_policy(&[]);
12191        let Some(enforcement) = mapped_enforcement(
12192            TEST,
12193            &policy,
12194            murmur_artifact::ContainmentClass::Scoped,
12195            base.path(),
12196            workdir.path(),
12197        ) else {
12198            return;
12199        };
12200        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12201        let decoy = base.path().join("decoy");
12202        std::fs::create_dir(&decoy).unwrap();
12203        std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
12204        std::os::unix::fs::symlink(&decoy, &folder).unwrap();
12205
12206        let error = crate::shell::execute_shell(
12207            "bash",
12208            &["-c", "echo x > /tmp/swapped"],
12209            &[],
12210            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12211            &policy,
12212            &enforcement,
12213        )
12214        .expect_err("a swapped folder fails the spawn")
12215        .to_string();
12216        println!("{error}");
12217        assert!(
12218            error.contains("could not map /tmp onto this session's temporary folder"),
12219            "{error}"
12220        );
12221        assert!(error.contains("opening the temporary folder"), "{error}");
12222        assert!(
12223            error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
12224            "{error}"
12225        );
12226        assert_eq!(
12227            std::fs::read_dir(&decoy).unwrap().count(),
12228            0,
12229            "the link's target was written"
12230        );
12231    }
12232
12233    /// A policy allowlisting `binary` with its interpreter runtime derived the way
12234    /// `stage_session` derives it, `work` excluded as the capsule-writable root, through a host
12235    /// facts cache of its own.
12236    fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
12237        let mut policy = CapabilityPolicy {
12238            shell_allow: vec![binary.to_string()],
12239            ..CapabilityPolicy::default()
12240        };
12241        let cache = tempfile::tempdir().unwrap();
12242        policy.shell_derived_interpreter_runtime =
12243            crate::interpreter_runtime::derive_interpreter_runtimes(
12244                &policy.shell_allow,
12245                &[],
12246                &[work],
12247                &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
12248            )
12249            .runtimes;
12250        policy
12251    }
12252
12253    /// What `program` prints when this host's `python3` runs it outside any sandbox.
12254    fn unsandboxed_python(program: &str) -> String {
12255        let output = std::process::Command::new("python3")
12256            .args(["-I", "-c", program])
12257            .output()
12258            .unwrap();
12259        assert!(output.status.success(), "{output:?}");
12260        String::from_utf8(output.stdout).unwrap().trim().to_string()
12261    }
12262
12263    /// Asserts the derived runtime holds the directories `encodings` and `_sqlite3` load from on
12264    /// this host, as the interpreter itself reports them, and returns the first: the stdlib.
12265    fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
12266        let stdlib = PathBuf::from(unsandboxed_python(
12267            "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
12268        ));
12269        let dynload = PathBuf::from(unsandboxed_python(
12270            "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
12271        ));
12272        let derived = &policy.shell_derived_interpreter_runtime;
12273        println!("derived: {derived:#?}");
12274        let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
12275        let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
12276        assert!(
12277            dirs.contains(&&canonical(&stdlib)),
12278            "{stdlib:?} not in {dirs:?}"
12279        );
12280        assert!(
12281            dirs.contains(&&canonical(&dynload)),
12282            "{dynload:?} not in {dirs:?}"
12283        );
12284        canonical(&stdlib)
12285    }
12286
12287    /// The program that opens `secret` and lists `parent`, each in its own `try`, printing the
12288    /// exception class name or `allowed`.
12289    fn python_probe_outside(secret: &Path, parent: &Path) -> String {
12290        format!(
12291            "import os\n\
12292             for label, act in (('open', lambda: open({secret:?}).read()), \
12293             ('listdir', lambda: os.listdir({parent:?}))):\n\
12294             \x20   try:\n\
12295             \x20       act(); print(label, 'allowed')\n\
12296             \x20   except Exception as error:\n\
12297             \x20       print(label, type(error).__name__)\n",
12298            secret = secret.to_string_lossy(),
12299            parent = parent.to_string_lossy(),
12300        )
12301    }
12302
12303    fn execute(
12304        binary: &str,
12305        args: &[&str],
12306        accessible: &Path,
12307        policy: &CapabilityPolicy,
12308        enforcement: &ShellEnforcement,
12309    ) -> crate::shell::ShellResult {
12310        let result = crate::shell::execute_shell(
12311            binary,
12312            args,
12313            &[],
12314            &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
12315            policy,
12316            enforcement,
12317        )
12318        .unwrap();
12319        println!(
12320            "exit {}\n--- stdout\n{}--- stderr\n{}---",
12321            result.exit_code, result.stdout, result.stderr
12322        );
12323        result
12324    }
12325
12326    /// A policy allowlisting `shell_allow` and reaching `network_allow`, with the trust store
12327    /// derived the way `stage_session` derives it, `work` excluded as the capsule-writable root.
12328    fn trust_store_policy(
12329        shell_allow: &[&str],
12330        network_allow: &[&str],
12331        work: &Path,
12332    ) -> CapabilityPolicy {
12333        let mut policy = CapabilityPolicy {
12334            shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
12335            network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
12336            ..CapabilityPolicy::default()
12337        };
12338        policy.shell_trust_store = crate::trust_store::derive_trust_store(
12339            &policy.shell_allow,
12340            &policy.network_allow,
12341            &[work],
12342        );
12343        println!("derived trust store: {:#?}", policy.shell_trust_store);
12344        policy
12345    }
12346
12347    /// The first CA bundle file of the candidate table this host has.
12348    fn host_ca_bundle() -> Option<PathBuf> {
12349        crate::trust_store::TRUST_STORE_CANDIDATES
12350            .iter()
12351            .filter(|candidate| !candidate.directory)
12352            .map(|candidate| PathBuf::from(candidate.path))
12353            .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
12354    }
12355
12356    /// An allowlisted `curl` in a capsule with a network allowlist and no `interpreter_runtime`
12357    /// block verifies a server certificate under `scoped`.
12358    #[test]
12359    fn a_scoped_curl_with_network_allow_completes_an_https_request() {
12360        let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
12361        if find_on_path("curl").is_none() {
12362            eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
12363            return;
12364        }
12365        let unsandboxed = std::process::Command::new("curl")
12366            .args([
12367                "-sS",
12368                "-o",
12369                "/dev/null",
12370                "-w",
12371                "%{http_code}",
12372                "--max-time",
12373                "15",
12374            ])
12375            .arg("https://example.com/")
12376            .output()
12377            .unwrap();
12378        if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
12379            eprintln!(
12380                "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
12381                 the sandbox; it gave {unsandboxed:?}"
12382            );
12383            return;
12384        }
12385        let (_base, work, _outside) = work_and_outside();
12386        let policy = trust_store_policy(&["curl"], &["example.com"], &work);
12387        assert!(policy.shell_interpreter_runtime.is_empty());
12388        assert!(
12389            !policy.shell_trust_store.is_empty(),
12390            "this host has a trust store the candidate table names"
12391        );
12392        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12393            return;
12394        };
12395
12396        let fetched = execute(
12397            "curl",
12398            &[
12399                "-sS",
12400                "-o",
12401                "/dev/null",
12402                "-w",
12403                "%{http_code}",
12404                "--max-time",
12405                "30",
12406                "https://example.com/",
12407            ],
12408            &work,
12409            &policy,
12410            &enforcement,
12411        );
12412        enforcement.end_session();
12413        assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
12414        assert_eq!(fetched.stdout, "200");
12415    }
12416
12417    /// The trust store reaches a command only when the capsule declares a network allowlist.
12418    #[test]
12419    fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
12420        let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
12421        assert!(
12422            crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
12423        );
12424        let Some(bundle) = host_ca_bundle() else {
12425            eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
12426            return;
12427        };
12428        let bundle_arg = bundle.to_str().unwrap();
12429        let (_base, work, _outside) = work_and_outside();
12430
12431        let offline = trust_store_policy(&["cat"], &[], &work);
12432        assert!(offline.shell_trust_store.is_empty());
12433        let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
12434        assert!(!online.shell_trust_store.is_empty());
12435        let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
12436            return;
12437        };
12438        for derived in &online.shell_trust_store {
12439            assert!(
12440                !enforcement
12441                    .landlock_grants
12442                    .iter()
12443                    .any(|grant| grant.path == derived.path),
12444                "{derived:?} granted without network.allow"
12445            );
12446        }
12447        let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
12448        enforcement.end_session();
12449        assert_ne!(denied.exit_code, 0);
12450        assert!(
12451            denied.stderr.contains("Permission denied"),
12452            "{}",
12453            denied.stderr
12454        );
12455
12456        let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
12457            return;
12458        };
12459        let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
12460        enforcement.end_session();
12461        assert_eq!(read.exit_code, 0, "{}", read.stderr);
12462        assert!(
12463            read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
12464            "{}",
12465            &read.stdout[..read.stdout.len().min(200)]
12466        );
12467    }
12468
12469    // ---- the session's PID namespace and its private /proc ----
12470
12471    /// A policy allowlisting `shell_allow` and nothing else.
12472    fn allow_only(shell_allow: &[&str]) -> CapabilityPolicy {
12473        CapabilityPolicy {
12474            shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
12475            ..CapabilityPolicy::default()
12476        }
12477    }
12478
12479    /// A `sealed` session's enforcement for `policy` from a live probe, or `None` (after a skip
12480    /// line naming `test`) on a host that does not reach `KernelSealed`.
12481    fn sealed_landlock_enforcement(
12482        test: &str,
12483        policy: &CapabilityPolicy,
12484    ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
12485        if crate::network_namespace::skip_without_egress_namespace(test) {
12486            return None;
12487        }
12488        let probe = HostProbe::probe();
12489        if probe.tier() != EnforcementTier::KernelSealed {
12490            eprintln!(
12491                "SKIP — PROVES NOTHING ABOUT SEALED ON THIS HOST: {test} needs a host that \
12492                 reaches KernelSealed; detected {:?}",
12493                probe.tier()
12494            );
12495            return None;
12496        }
12497        let runtime = tempfile::tempdir().unwrap();
12498        let enforcement = ShellEnforcement::resolve_declared(
12499            policy,
12500            murmur_artifact::ContainmentClass::Sealed,
12501            probe,
12502        )
12503        .unwrap()
12504        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
12505        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
12506        Some((enforcement, runtime))
12507    }
12508
12509    /// Whether `enforcement` mounts a private `/proc`, printing a skip line naming `test` when
12510    /// this host's probe could not mount one.
12511    fn mounts_private_proc(test: &str, enforcement: &ShellEnforcement) -> bool {
12512        match enforcement.installed_proc_view() {
12513            crate::proc_view::ProcView::Private => true,
12514            crate::proc_view::ProcView::Host(reason) => {
12515                eprintln!(
12516                    "SKIP — PROVES NOTHING: {test} needs a host that mounts a private procfs; \
12517                     {}",
12518                    reason.describe()
12519                );
12520                false
12521            }
12522        }
12523    }
12524
12525    /// The script the scoped and sealed own-`/proc` tests run: the files `node`, `os.cpus()` and
12526    /// every runtime that reads its own process read, each through `/proc`.
12527    const OWN_PROC_SCRIPT: &str = "head -n 1 /proc/self/status; \
12528        head -n 1 /proc/thread-self/status; \
12529        head -n 1 /proc/stat; \
12530        n=0; while read -r line; do case $line in processor*) n=$((n+1));; esac; done \
12531        < /proc/cpuinfo; echo \"processors=$n\"; \
12532        head -n 1 /proc/meminfo; \
12533        while read -r _ point kind options _; do \
12534        if [ \"$point\" = /proc ]; then echo \"proc-mount: $kind $options\"; fi; \
12535        done < /proc/self/mounts";
12536
12537    fn assert_reads_own_proc(result: &crate::shell::ShellResult) {
12538        assert_eq!(result.exit_code, 0, "{}", result.stderr);
12539        assert!(
12540            !result.stderr.contains("Permission denied"),
12541            "{}",
12542            result.stderr
12543        );
12544        let lines: Vec<&str> = result.stdout.lines().collect();
12545        assert!(lines.len() >= 5, "{}", result.stdout);
12546        assert!(lines[0].starts_with("Name:"), "{}", result.stdout);
12547        assert!(lines[1].starts_with("Name:"), "{}", result.stdout);
12548        assert!(lines[2].starts_with("cpu "), "{}", result.stdout);
12549        let processors: u32 = lines[3]
12550            .strip_prefix("processors=")
12551            .and_then(|n| n.parse().ok())
12552            .unwrap_or_else(|| panic!("{}", result.stdout));
12553        assert!(processors > 0, "{}", result.stdout);
12554        assert!(lines[4].starts_with("MemTotal:"), "{}", result.stdout);
12555    }
12556
12557    /// A `scoped` command reads its own `/proc/self/*` and `/proc/thread-self/*`, and the
12558    /// kernel's `stat`, `cpuinfo` and `meminfo`, through the private `/proc` and its read rule.
12559    #[test]
12560    fn a_scoped_command_reads_its_own_proc() {
12561        let test = "a_scoped_command_reads_its_own_proc";
12562        let (_base, work, _outside) = work_and_outside();
12563        let policy = allow_only(&["bash", "cat", "head"]);
12564        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12565            return;
12566        };
12567        if !mounts_private_proc(test, &enforcement) {
12568            return;
12569        }
12570        let result = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
12571        enforcement.end_session();
12572        assert_reads_own_proc(&result);
12573    }
12574
12575    /// A process outside every capsule, which the test kills when it goes out of scope.
12576    struct HostSleeper(std::process::Child);
12577
12578    impl HostSleeper {
12579        fn start() -> Self {
12580            Self(
12581                std::process::Command::new("sleep")
12582                    .arg("300")
12583                    .spawn()
12584                    .unwrap(),
12585            )
12586        }
12587
12588        fn pid(&self) -> u32 {
12589            self.0.id()
12590        }
12591    }
12592
12593    impl Drop for HostSleeper {
12594        fn drop(&mut self) {
12595            let _ = self.0.kill();
12596            let _ = self.0.wait();
12597        }
12598    }
12599
12600    /// The script the scoped and sealed host-process tests run against a host process `pid`.
12601    fn host_process_script(pid: u32) -> String {
12602        format!("ls /proc; cat /proc/{pid}/cmdline; echo \"cat=$?\"")
12603    }
12604
12605    fn assert_sees_no_host_process(result: &crate::shell::ShellResult, host_pids: &[u32]) {
12606        assert_eq!(result.exit_code, 0, "{}", result.stderr);
12607        let entries: Vec<u32> = result
12608            .stdout
12609            .lines()
12610            .filter_map(|line| line.trim().parse().ok())
12611            .collect();
12612        assert!(!entries.is_empty(), "no process listed: {}", result.stdout);
12613        // The command's own tree: bash and the `ls` it forked. The holder is hidden.
12614        assert!(entries.len() <= 3, "{entries:?}");
12615        for pid in host_pids {
12616            assert!(!entries.contains(pid), "host pid {pid} listed: {entries:?}");
12617        }
12618        assert!(
12619            result.stderr.contains("No such file or directory"),
12620            "{}",
12621            result.stderr
12622        );
12623        assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
12624    }
12625
12626    /// A `scoped` command's `/proc` lists only its own tree. A host process the test started
12627    /// outside any capsule, and the test itself, are absent, and opening one's `cmdline` is
12628    /// `ENOENT`, not `EACCES`.
12629    #[test]
12630    fn a_scoped_command_cannot_see_host_processes() {
12631        let test = "a_scoped_command_cannot_see_host_processes";
12632        let (_base, work, _outside) = work_and_outside();
12633        let policy = allow_only(&["bash", "ls", "cat"]);
12634        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12635            return;
12636        };
12637        if !mounts_private_proc(test, &enforcement) {
12638            return;
12639        }
12640        let sleeper = HostSleeper::start();
12641        let result = run_bash_in(
12642            &work,
12643            &host_process_script(sleeper.pid()),
12644            &policy,
12645            &enforcement,
12646        );
12647        enforcement.end_session();
12648        assert_sees_no_host_process(&result, &[sleeper.pid(), std::process::id()]);
12649    }
12650
12651    /// The OpenSSL configuration file a Node build reads at startup.
12652    const NODE_OPENSSL_CONFIG: &str = "/etc/ssl/openssl.cnf";
12653
12654    /// `node` under `scoped` counts the host's CPUs and resolves `localhost` to loopback, with
12655    /// no `network.allow` and no grant on `/proc` or `/etc`.
12656    #[test]
12657    fn a_scoped_node_counts_cpus_and_resolves_localhost() {
12658        let test = "a_scoped_node_counts_cpus_and_resolves_localhost";
12659        if resolve_exec_allowlist(&["node".to_string()]).is_empty() {
12660            eprintln!("SKIP — PROVES NOTHING: {test} needs node on PATH");
12661            return;
12662        }
12663        let (_base, work, _outside) = work_and_outside();
12664        let mut policy = allow_only(&["node"]);
12665        // V8's address-space reservation; see `W-RUN-010`.
12666        policy.resources.memory_bytes = 64 * 1024 * 1024 * 1024;
12667        // Node reads OpenSSL's configuration at startup and exits 13 when it is refused, so it is
12668        // granted the one file, as the how-to grants it; nothing here grants `/proc` or `/etc/hosts`.
12669        if Path::new(NODE_OPENSSL_CONFIG).is_file() {
12670            policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
12671                binary: "node".to_string(),
12672                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
12673                    path: NODE_OPENSSL_CONFIG.to_string(),
12674                    list_dir: false,
12675                }],
12676            }];
12677        }
12678        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12679            return;
12680        };
12681        if !mounts_private_proc(test, &enforcement) {
12682            return;
12683        }
12684        let result = execute(
12685            "node",
12686            &[
12687                "-e",
12688                "console.log('cpus=' + require('os').cpus().length); \
12689                 require('dns').lookup('localhost', (error, address) => \
12690                 console.log(error ? 'lookup=' + error.code : 'lookup=' + address));",
12691            ],
12692            &work,
12693            &policy,
12694            &enforcement,
12695        );
12696        enforcement.end_session();
12697        assert_eq!(result.exit_code, 0, "{}", result.stderr);
12698        let cpus: u32 = result
12699            .stdout
12700            .lines()
12701            .find_map(|line| line.strip_prefix("cpus="))
12702            .and_then(|n| n.parse().ok())
12703            .unwrap_or_else(|| panic!("{}", result.stdout));
12704        assert!(cpus > 0, "{}", result.stdout);
12705        let address = result
12706            .stdout
12707            .lines()
12708            .find_map(|line| line.strip_prefix("lookup="))
12709            .unwrap_or_else(|| panic!("{}", result.stdout));
12710        assert!(
12711            address == "127.0.0.1" || address == "::1",
12712            "{}",
12713            result.stdout
12714        );
12715    }
12716
12717    /// A `scoped` command resolves `localhost` through `/etc/hosts`: both name-resolution
12718    /// files read, and the rest of `/etc` — `/etc/passwd`, `/etc/resolv.conf` — stays denied.
12719    #[test]
12720    fn a_scoped_command_resolves_localhost_through_etc_hosts() {
12721        let test = "a_scoped_command_resolves_localhost_through_etc_hosts";
12722        let (_base, work, _outside) = work_and_outside();
12723        let policy = allow_only(&["bash", "cat", "getent"]);
12724        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12725            return;
12726        };
12727        let result = run_bash_in(
12728            &work,
12729            "getent hosts localhost; echo \"getent=$?\"; \
12730             cat /etc/hosts > /dev/null; echo \"hosts=$?\"; \
12731             cat /etc/nsswitch.conf > /dev/null; echo \"nsswitch=$?\"; \
12732             cat /etc/passwd > /dev/null; echo \"passwd=$?\"; \
12733             cat /etc/resolv.conf > /dev/null; echo \"resolv=$?\"",
12734            &policy,
12735            &enforcement,
12736        );
12737        enforcement.end_session();
12738        let stdout = &result.stdout;
12739        assert!(
12740            stdout
12741                .lines()
12742                .any(|line| line.starts_with("127.0.0.1") || line.starts_with("::1")),
12743            "{stdout}"
12744        );
12745        for read in ["getent=0", "hosts=0", "nsswitch=0"] {
12746            assert!(stdout.lines().any(|line| line == read), "{read}: {stdout}");
12747        }
12748        for denied in ["passwd=1", "resolv=1"] {
12749            assert!(
12750                stdout.lines().any(|line| line == denied),
12751                "{denied}: {stdout}"
12752            );
12753        }
12754        assert!(
12755            result.stderr.contains("/etc/passwd: Permission denied"),
12756            "{}",
12757            result.stderr
12758        );
12759        assert!(
12760            result
12761                .stderr
12762                .contains("/etc/resolv.conf: Permission denied"),
12763            "{}",
12764            result.stderr
12765        );
12766    }
12767
12768    /// The exit code and the terminating signal the runtime sees are the command's, not its
12769    /// relay's, and a command's parent reads as `0` — outside its PID namespace.
12770    #[test]
12771    fn a_command_under_its_relay_keeps_its_exit_status_and_signal() {
12772        let test = "a_command_under_its_relay_keeps_its_exit_status_and_signal";
12773        let (_base, work, _outside) = work_and_outside();
12774        let policy = allow_only(&["bash"]);
12775        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12776            return;
12777        };
12778        let exited = run_bash_in(&work, "exit 7", &policy, &enforcement);
12779        let killed = run_bash_in(&work, "kill -KILL $$", &policy, &enforcement);
12780        let parent = run_bash_in(&work, "echo \"ppid=$PPID pid=$$\"", &policy, &enforcement);
12781        enforcement.end_session();
12782        assert_eq!(exited.exit_code, 7);
12783        assert_eq!(killed.exit_code, 128 + libc::SIGKILL);
12784        assert!(
12785            parent.stdout.starts_with("ppid=0 pid="),
12786            "{}",
12787            parent.stdout
12788        );
12789        let pid: i32 = parent
12790            .stdout
12791            .trim()
12792            .rsplit('=')
12793            .next()
12794            .unwrap()
12795            .parse()
12796            .unwrap();
12797        assert!(pid > 1 && pid < 100, "a namespace pid: {}", parent.stdout);
12798    }
12799
12800    /// The host pid of `namespace_pid` in this test process's sessions, polled briefly: the pid
12801    /// file is written before the process it names is guaranteed to be visible.
12802    fn host_pid(namespace_pid: i32) -> i32 {
12803        for _ in 0..100 {
12804            if let Some(pid) =
12805                crate::network_namespace::host_pid_in_session(std::process::id(), namespace_pid)
12806            {
12807                return pid;
12808            }
12809            std::thread::sleep(std::time::Duration::from_millis(20));
12810        }
12811        panic!("no process of this test's sessions is pid {namespace_pid} in its namespace");
12812    }
12813
12814    /// A server a command backgrounds survives into the next command, which sees it in the
12815    /// same PID namespace, and is gone with the namespace's holder once the session ends.
12816    #[test]
12817    fn a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session() {
12818        let test =
12819            "a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session";
12820        let (_base, work, _outside) = work_and_outside();
12821        let policy = allow_only(&["bash", "sleep"]);
12822        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12823            return;
12824        };
12825        let started = run_bash_in(
12826            &work,
12827            "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
12828            &policy,
12829            &enforcement,
12830        );
12831        assert_eq!(started.exit_code, 0, "{}", started.stderr);
12832        let namespace_pid = pid_in(&work, "sleeper.pid");
12833        let sleeper = host_pid(namespace_pid);
12834        let holder = host_pid(1);
12835        assert_ne!(sleeper, namespace_pid, "the pid file holds a namespace pid");
12836        let seen = run_bash_in(
12837            &work,
12838            "kill -0 \"$(< sleeper.pid)\" && echo alive",
12839            &policy,
12840            &enforcement,
12841        );
12842        assert_eq!(seen.stdout.trim(), "alive", "{}", seen.stderr);
12843        assert!(process_exists(sleeper));
12844        enforcement.end_session();
12845        let wait = SESSION_END_KILL_WAIT + std::time::Duration::from_secs(3);
12846        assert!(
12847            process_gone_within(holder, wait),
12848            "holder {holder} outlived the session"
12849        );
12850        assert!(
12851            process_gone_within(sleeper, wait),
12852            "sleeper {sleeper} outlived the session"
12853        );
12854    }
12855
12856    /// A session dropped without `end_session` closes its holder's lifeline, which ends the
12857    /// holder and every process in its PID namespace.
12858    #[test]
12859    fn a_dropped_session_ends_its_holder_and_its_processes() {
12860        let test = "a_dropped_session_ends_its_holder_and_its_processes";
12861        let (_base, work, _outside) = work_and_outside();
12862        let policy = allow_only(&["bash", "sleep"]);
12863        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12864            return;
12865        };
12866        let started = run_bash_in(
12867            &work,
12868            "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
12869            &policy,
12870            &enforcement,
12871        );
12872        assert_eq!(started.exit_code, 0, "{}", started.stderr);
12873        let sleeper = host_pid(pid_in(&work, "sleeper.pid"));
12874        let holder = host_pid(1);
12875        drop(enforcement);
12876        assert!(
12877            process_gone_within(holder, SESSION_END_KILL_WAIT),
12878            "holder {holder} outlived its dropped session"
12879        );
12880        assert!(
12881            process_gone_within(sleeper, SESSION_END_KILL_WAIT),
12882            "sleeper {sleeper} outlived its dropped session"
12883        );
12884    }
12885
12886    /// `Child::kill()` reaches the relay, the process `Child` refers to, and the command dies
12887    /// with it through `PR_SET_PDEATHSIG`; `wait` then reports the kill.
12888    #[test]
12889    fn killing_the_relay_ends_the_command() {
12890        use std::os::unix::process::{CommandExt, ExitStatusExt};
12891        let test = "killing_the_relay_ends_the_command";
12892        let (_base, work, _outside) = work_and_outside();
12893        let policy = allow_only(&["bash", "sleep"]);
12894        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12895            return;
12896        };
12897        let bash = resolve_exec_allowlist(&["bash".to_string()])
12898            .into_iter()
12899            .next()
12900            .unwrap();
12901        let mut command = std::process::Command::new(bash);
12902        command
12903            .args(["-c", "echo $$ > command.pid; exec sleep 300"])
12904            .current_dir(&work)
12905            .process_group(0);
12906        let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
12907        let mut relay = command.spawn().unwrap();
12908        let pid_file = work.join("command.pid");
12909        for _ in 0..200 {
12910            if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
12911                break;
12912            }
12913            std::thread::sleep(std::time::Duration::from_millis(20));
12914        }
12915        let command_pid = host_pid(pid_in(&work, "command.pid"));
12916        assert_ne!(
12917            command_pid,
12918            relay.id() as i32,
12919            "the command is not its relay"
12920        );
12921        relay.kill().unwrap();
12922        let status = relay.wait().unwrap();
12923        assert_eq!(status.signal(), Some(libc::SIGKILL));
12924        assert!(
12925            process_gone_within(command_pid, std::time::Duration::from_secs(5)),
12926            "command {command_pid} outlived its relay"
12927        );
12928        enforcement.end_session();
12929    }
12930
12931    /// A process-group `SIGTERM`, which a detached `stop` sends, reaches the command past its
12932    /// relay, which blocks every signal, and the relay then leaves the command's terminating
12933    /// signal as its own status.
12934    #[test]
12935    fn a_process_group_sigterm_reaches_the_command_past_its_relay() {
12936        use std::os::unix::process::{CommandExt, ExitStatusExt};
12937        let test = "a_process_group_sigterm_reaches_the_command_past_its_relay";
12938        let (_base, work, _outside) = work_and_outside();
12939        let policy = allow_only(&["bash", "sleep"]);
12940        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12941            return;
12942        };
12943        let bash = resolve_exec_allowlist(&["bash".to_string()])
12944            .into_iter()
12945            .next()
12946            .unwrap();
12947        let mut command = std::process::Command::new(bash);
12948        command
12949            .args(["-c", "echo $$ > command.pid; exec sleep 300"])
12950            .current_dir(&work)
12951            .process_group(0);
12952        let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
12953        let mut relay = command.spawn().unwrap();
12954        let pid_file = work.join("command.pid");
12955        for _ in 0..200 {
12956            if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
12957                break;
12958            }
12959            std::thread::sleep(std::time::Duration::from_millis(20));
12960        }
12961        let command_pid = host_pid(pid_in(&work, "command.pid"));
12962        let sent_at = std::time::Instant::now();
12963        assert!(crate::formation_launch::signal_group(
12964            i64::from(relay.id()),
12965            libc::SIGTERM
12966        ));
12967        let status = relay.wait().unwrap();
12968        assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
12969        assert!(
12970            sent_at.elapsed() < std::time::Duration::from_secs(5),
12971            "the command took {:?} to end",
12972            sent_at.elapsed()
12973        );
12974        assert!(
12975            process_gone_within(command_pid, std::time::Duration::from_secs(5)),
12976            "command {command_pid} outlived the group SIGTERM"
12977        );
12978        enforcement.end_session();
12979    }
12980
12981    /// The own-`/proc` and host-process scripts under `sealed`, where the composed root binds the
12982    /// private `/proc` the command mounted before it.
12983    #[test]
12984    fn a_sealed_command_reads_its_own_proc_and_sees_no_host_process() {
12985        let test = "a_sealed_command_reads_its_own_proc_and_sees_no_host_process";
12986        let (_base, work, _outside) = work_and_outside();
12987        let policy = allow_only(&["bash", "cat", "head", "ls"]);
12988        let Some((enforcement, _runtime)) = sealed_landlock_enforcement(test, &policy) else {
12989            return;
12990        };
12991        if !mounts_private_proc(test, &enforcement) {
12992            return;
12993        }
12994        let own = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
12995        let sleeper = HostSleeper::start();
12996        let host = run_bash_in(
12997            &work,
12998            &host_process_script(sleeper.pid()),
12999            &policy,
13000            &enforcement,
13001        );
13002        enforcement.end_session();
13003        assert_reads_own_proc(&own);
13004        assert_sees_no_host_process(&host, &[sleeper.pid(), std::process::id()]);
13005    }
13006
13007    /// A session whose probe could not mount a private `/proc` runs its commands with the
13008    /// host's, where every open beneath `/proc` is refused as before, and says why.
13009    #[test]
13010    fn a_host_proc_view_keeps_proc_denied_and_says_why() {
13011        let test = "a_host_proc_view_keeps_proc_denied_and_says_why";
13012        let (_base, work, _outside) = work_and_outside();
13013        let policy = allow_only(&["bash", "cat", "ls"]);
13014        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13015            return;
13016        };
13017        let refused =
13018            crate::proc_view::ProcView::Host(crate::proc_view::HostProcReason::MountRefused(
13019                "mounting proc over /proc failed with EPERM".to_string(),
13020            ));
13021        let enforcement = enforcement.with_proc_view(refused.clone());
13022        assert_eq!(enforcement.installed_proc_view(), refused);
13023        let result = run_bash_in(
13024            &work,
13025            "cat /proc/self/status; echo \"cat=$?\"; ls /proc/1 >/dev/null; echo \"ls=$?\"",
13026            &policy,
13027            &enforcement,
13028        );
13029        enforcement.end_session();
13030        assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
13031        assert!(
13032            result
13033                .stderr
13034                .contains("/proc/self/status: Permission denied"),
13035            "{}",
13036            result.stderr
13037        );
13038        let report = crate::proc_view::process_view_report(&refused);
13039        assert!(!report.private);
13040        assert!(report
13041            .not_private_because
13042            .is_some_and(|reason| reason.contains("failed with EPERM")),);
13043    }
13044
13045    /// A private-`/proc` mount that fails at spawn fails the spawn, naming the step and its
13046    /// `errno` under the fixed prefix; nothing runs with the host's `/proc` instead.
13047    #[test]
13048    fn a_private_proc_mount_that_fails_at_spawn_fails_the_spawn() {
13049        let test = "a_private_proc_mount_that_fails_at_spawn_fails_the_spawn";
13050        let (_base, work, _outside) = work_and_outside();
13051        let policy = allow_only(&["bash"]);
13052        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13053            return;
13054        };
13055        if !mounts_private_proc(test, &enforcement) {
13056            return;
13057        }
13058        let _armed = ForcePrivateProcFailureGuard::new();
13059        let error = crate::shell::execute_shell(
13060            "bash",
13061            &["-c", "echo ran > ran.txt"],
13062            &[],
13063            &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
13064            &policy,
13065            &enforcement,
13066        )
13067        .expect_err("a failed private /proc mount must fail the spawn");
13068        enforcement.end_session();
13069        let error = error.to_string();
13070        assert!(
13071            error.contains(crate::proc_view::PRIVATE_PROC_FAILURE_PREFIX),
13072            "{error}"
13073        );
13074        assert!(
13075            error.contains("mounting proc over /proc failed: EPERM"),
13076            "{error}"
13077        );
13078        assert!(!work.join("ran.txt").exists());
13079    }
13080
13081    /// Every name-resolution file `scoped` grants is one `sealed` already grants, and the
13082    /// resolver issues them read-only, unlistable and unexecutable on `KernelFull` alone.
13083    #[test]
13084    fn scoped_name_resolution_grants_are_a_read_only_subset_of_sealed_etc() {
13085        for path in SCOPED_NAME_RESOLUTION_PATHS {
13086            assert!(
13087                crate::sealed::SEALED_ETC_PATHS
13088                    .iter()
13089                    .any(|entry| entry.path == *path && !entry.directory),
13090                "{path} is not a SEALED_ETC_PATHS file"
13091            );
13092        }
13093        let grants = resolve_scoped_name_resolution_landlock_grants(EnforcementTier::KernelFull);
13094        assert_eq!(
13095            grants
13096                .iter()
13097                .map(|grant| grant.path.clone())
13098                .collect::<Vec<_>>(),
13099            vec![
13100                PathBuf::from("/etc/hosts"),
13101                PathBuf::from("/etc/nsswitch.conf")
13102            ]
13103        );
13104        assert!(grants
13105            .iter()
13106            .all(|grant| !grant.list_dir && !grant.executable));
13107        for tier in [
13108            EnforcementTier::KernelSealed,
13109            EnforcementTier::KernelSeccompOnly,
13110            EnforcementTier::EnvironmentOnly,
13111            EnforcementTier::Unconfined,
13112        ] {
13113            assert!(resolve_scoped_name_resolution_landlock_grants(tier).is_empty());
13114        }
13115    }
13116
13117    /// A creator that cannot create the PID namespace is refused with `E-CAP-005` naming that
13118    /// step, at the session's first command, and nothing runs.
13119    #[test]
13120    fn a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005() {
13121        let test = "a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005";
13122        let (_base, work, _outside) = work_and_outside();
13123        let policy = allow_only(&["bash"]);
13124        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13125            return;
13126        };
13127        let _armed = crate::network_namespace::ForcePidNamespaceFailure::arm();
13128        let error = crate::shell::execute_shell(
13129            "bash",
13130            &["-c", "echo ran > ran.txt"],
13131            &[],
13132            &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
13133            &policy,
13134            &enforcement,
13135        )
13136        .expect_err("a session without its PID namespace must not run");
13137        enforcement.end_session();
13138        let error = error.to_string();
13139        assert!(error.contains("E-CAP-005"), "{error}");
13140        assert!(
13141            error
13142                .contains("unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID) failed with EPERM"),
13143            "{error}"
13144        );
13145        assert!(error.contains("CLONE_NEWPID"), "{error}");
13146        assert!(!work.join("ran.txt").exists());
13147    }
13148
13149    /// The trust-store grant reaches the certificates and nothing beside them: not the directory
13150    /// above, its OpenSSL configuration or its private keys, and nothing is writable.
13151    #[test]
13152    fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
13153        let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
13154        let (_base, work, _outside) = work_and_outside();
13155        let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
13156        for entry in &policy.shell_trust_store {
13157            for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
13158                assert_ne!(entry.path, Path::new(broad));
13159            }
13160            assert!(
13161                !entry.path.components().any(|c| c.as_os_str() == "private"),
13162                "{entry:?}"
13163            );
13164        }
13165        assert!(resolve_trust_store_grants(&policy.shell_trust_store)
13166            .iter()
13167            .all(|grant| !grant.executable));
13168        if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
13169            eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
13170            return;
13171        }
13172        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13173            return;
13174        };
13175
13176        let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
13177        assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
13178        let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
13179        assert_ne!(above.exit_code, 0);
13180        assert!(
13181            above.stderr.contains("Permission denied"),
13182            "{}",
13183            above.stderr
13184        );
13185        if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
13186            let config = execute(
13187                "cat",
13188                &["/etc/ssl/openssl.cnf"],
13189                &work,
13190                &policy,
13191                &enforcement,
13192            );
13193            assert_ne!(config.exit_code, 0);
13194            assert!(
13195                config.stderr.contains("Permission denied"),
13196                "{}",
13197                config.stderr
13198            );
13199        }
13200        let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
13201        assert_ne!(private.exit_code, 0);
13202        let probe = Path::new("/etc/ssl/certs/murmur-probe");
13203        let written = execute(
13204            "bash",
13205            &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
13206            &work,
13207            &policy,
13208            &enforcement,
13209        );
13210        enforcement.end_session();
13211        assert_ne!(written.exit_code, 0);
13212        assert!(std::fs::symlink_metadata(probe).is_err());
13213    }
13214
13215    /// A `scoped` command cannot write into `~/.murmur/host_facts`: with the home outside the
13216    /// workdir, a shell redirection there is refused and leaves no file.
13217    #[test]
13218    fn a_scoped_command_cannot_write_the_host_facts_cache() {
13219        let test = "a_scoped_command_cannot_write_the_host_facts_cache";
13220        let home = home_tempdir();
13221        let workdir = home_tempdir();
13222        let cache = home.path().join(".murmur").join(
13223            crate::murmur_home::HomeEntryKind::HostFacts
13224                .file_name()
13225                .unwrap(),
13226        );
13227        crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
13228        crate::murmur_home::hold_private_dir(&cache).unwrap();
13229        let policy = coreutils_policy(&[]);
13230        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13231            return;
13232        };
13233        let probe = cache.join("probe");
13234        let written = run_bash_in(
13235            workdir.path(),
13236            &format!("printf x > '{}'", probe.display()),
13237            &policy,
13238            &enforcement,
13239        );
13240        enforcement.end_session();
13241        assert_ne!(written.exit_code, 0);
13242        assert!(
13243            written.stderr.contains("Permission denied"),
13244            "{}",
13245            written.stderr
13246        );
13247        assert!(std::fs::symlink_metadata(&probe).is_err());
13248    }
13249
13250    #[test]
13251    fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
13252        let paths = [
13253            crate::trust_store::TrustStorePath {
13254                path: PathBuf::from("/etc/ssl/certs"),
13255                directory: true,
13256            },
13257            crate::trust_store::TrustStorePath {
13258                path: PathBuf::from("/etc/pki/tls/cacert.pem"),
13259                directory: false,
13260            },
13261        ];
13262        assert_eq!(
13263            resolve_trust_store_grants(&paths),
13264            vec![
13265                LandlockGrant {
13266                    path: PathBuf::from("/etc/ssl/certs"),
13267                    list_dir: true,
13268                    executable: false,
13269                },
13270                LandlockGrant {
13271                    path: PathBuf::from("/etc/pki/tls/cacert.pem"),
13272                    list_dir: false,
13273                    executable: false,
13274                },
13275            ]
13276        );
13277        assert!(resolve_trust_store_grants(&[]).is_empty());
13278    }
13279
13280    /// `ShellEnforcement::resolve` adds exactly the policy's trust-store grants on both Landlock
13281    /// tiers, whatever the host, and leaves the sealed `/etc` grants in place.
13282    #[test]
13283    fn shell_enforcement_resolve_adds_the_trust_store() {
13284        use murmur_artifact::ContainmentClass;
13285        let store = vec![
13286            crate::trust_store::TrustStorePath {
13287                path: PathBuf::from("/murmur-test/certs"),
13288                directory: true,
13289            },
13290            crate::trust_store::TrustStorePath {
13291                path: PathBuf::from("/murmur-test/bundle.pem"),
13292                directory: false,
13293            },
13294        ];
13295        let expected = resolve_trust_store_grants(&store);
13296        let with_store = CapabilityPolicy {
13297            shell_trust_store: store.clone(),
13298            ..CapabilityPolicy::default()
13299        };
13300        let without = CapabilityPolicy::default();
13301        for (tier, declared) in [
13302            (EnforcementTier::KernelFull, ContainmentClass::Scoped),
13303            (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
13304        ] {
13305            let decision =
13306                crate::containment::ContainmentDecision::decide(tier, declared, false, false);
13307            let granted = |policy: &CapabilityPolicy| {
13308                let enforcement =
13309                    ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
13310                assert_eq!(enforcement.tier, tier);
13311                enforcement.landlock_grants
13312            };
13313            let with_grants = granted(&with_store);
13314            let without_grants = granted(&without);
13315            for grant in &expected {
13316                assert_eq!(
13317                    with_grants.iter().filter(|g| *g == grant).count(),
13318                    1,
13319                    "{tier:?}: {grant:?}"
13320                );
13321            }
13322            assert!(without_grants
13323                .iter()
13324                .all(|grant| !grant.path.starts_with("/murmur-test")));
13325            assert_eq!(with_grants.len(), without_grants.len() + expected.len());
13326            let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
13327            assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
13328            for grant in &sealed_etc {
13329                assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
13330            }
13331        }
13332    }
13333
13334    /// An allowlisted `python3` with no `interpreter_runtime` block imports its standard library
13335    /// and a C extension needing a library outside its own closure under `scoped`, while a file
13336    /// outside its runtime and the directory above its stdlib stay denied.
13337    #[test]
13338    fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
13339        let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
13340        if find_on_path("python3").is_none() {
13341            println!("[SKIP] {test}: no python3 on PATH");
13342            return;
13343        }
13344        let (_base, work, outside) = work_and_outside();
13345        let policy = derived_interpreter_policy("python3", &work);
13346        let stdlib = assert_derives_stdlib_and_sqlite(&policy);
13347        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13348            return;
13349        };
13350
13351        let imported = execute(
13352            "python3",
13353            &["-c", "import json, sqlite3, venv; print('imported')"],
13354            &work,
13355            &policy,
13356            &enforcement,
13357        );
13358        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13359        assert_eq!(imported.stdout.trim(), "imported");
13360
13361        let secret = outside.join("secret.txt");
13362        std::fs::write(&secret, "secret").unwrap();
13363        let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
13364        let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
13365        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13366        assert_eq!(
13367            denied.stdout.lines().collect::<Vec<_>>(),
13368            vec!["open PermissionError", "listdir PermissionError"]
13369        );
13370    }
13371
13372    /// The same interpreter under `sealed`: it imports what it imported under `scoped`, and the
13373    /// file outside its runtime is neither readable nor present in the composed root.
13374    #[test]
13375    fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
13376        let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
13377        if crate::network_namespace::skip_without_egress_namespace(test) {
13378            return;
13379        }
13380        if find_on_path("python3").is_none() {
13381            println!("[SKIP] {test}: no python3 on PATH");
13382            return;
13383        }
13384        let probe = HostProbe::probe();
13385        if probe.tier() != EnforcementTier::KernelSealed {
13386            eprintln!(
13387                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
13388                 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
13389                probe.tier()
13390            );
13391            return;
13392        }
13393        let (_base, work, outside) = work_and_outside();
13394        let policy = derived_interpreter_policy("python3", &work);
13395        assert_derives_stdlib_and_sqlite(&policy);
13396        let runtime = tempfile::tempdir().unwrap();
13397        let enforcement = ShellEnforcement::resolve_declared(
13398            &policy,
13399            murmur_artifact::ContainmentClass::Sealed,
13400            probe,
13401        )
13402        .expect("a sealed-capable host resolves sealed enforcement")
13403        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13404        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
13405
13406        let imported = execute(
13407            "python3",
13408            &["-c", "import json, sqlite3, venv; print('imported')"],
13409            &work,
13410            &policy,
13411            &enforcement,
13412        );
13413        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13414        assert_eq!(imported.stdout.trim(), "imported");
13415
13416        let secret = outside.join("secret.txt");
13417        std::fs::write(&secret, "secret").unwrap();
13418        let program = format!(
13419            "try:\n    open({:?}).read(); print('allowed')\nexcept Exception as error:\n    \
13420             print(type(error).__name__)\n",
13421            secret.to_string_lossy()
13422        );
13423        let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
13424        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13425        let outcome = denied.stdout.trim();
13426        assert!(
13427            outcome == "PermissionError" || outcome == "FileNotFoundError",
13428            "{outcome}"
13429        );
13430    }
13431
13432    /// An allowlisted `perl` with no `interpreter_runtime` block loads `POSIX`, an XS module, out
13433    /// of its own `@INC` under `scoped`.
13434    #[test]
13435    fn a_scoped_perl_loads_posix_from_its_inc() {
13436        let test = "a_scoped_perl_loads_posix_from_its_inc";
13437        if find_on_path("perl").is_none() {
13438            println!("[SKIP] {test}: no perl on PATH");
13439            return;
13440        }
13441        let (_base, work, _outside) = work_and_outside();
13442        let policy = derived_interpreter_policy("perl", &work);
13443        assert!(
13444            !policy.shell_derived_interpreter_runtime.is_empty(),
13445            "perl was not recognised or reported nothing"
13446        );
13447        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13448            return;
13449        };
13450        let result = execute(
13451            "perl",
13452            &["-MPOSIX", "-e", "print qq{loaded\n}"],
13453            &work,
13454            &policy,
13455            &enforcement,
13456        );
13457        assert_eq!(result.exit_code, 0, "{}", result.stderr);
13458        assert_eq!(result.stdout.trim(), "loaded");
13459    }
13460
13461    // ---------------------------------------------------------------- host paths
13462
13463    /// The rights each host-path rule is built with: `ReadFile | ReadDir` for `read`, the
13464    /// workdir's right-set without `Execute` for `read-write`, and `Execute` never.
13465    #[test]
13466    fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
13467        use landlock::AccessFs;
13468        assert_eq!(
13469            linux_enforce::host_path_access_rights(false),
13470            AccessFs::ReadFile | AccessFs::ReadDir
13471        );
13472        assert_eq!(
13473            linux_enforce::host_path_access_rights(true),
13474            linux_enforce::workdir_access_rights(false)
13475        );
13476        for read_write in [false, true] {
13477            assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
13478        }
13479    }
13480
13481    /// `ro/` holding `in.txt`, `rw/` and `outside/` holding `secret.txt`, under `HOME` (never
13482    /// `/tmp`, which a host path may not be under), with the two host paths a test lends.
13483    struct HostPathDirs {
13484        _base: tempfile::TempDir,
13485        ro: PathBuf,
13486        rw: PathBuf,
13487        outside: PathBuf,
13488    }
13489
13490    impl HostPathDirs {
13491        fn new() -> Self {
13492            let base = home_tempdir();
13493            let root = std::fs::canonicalize(base.path()).unwrap();
13494            let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
13495            for dir in [&ro, &rw, &outside] {
13496                std::fs::create_dir(dir).unwrap();
13497            }
13498            std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
13499            std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
13500            Self {
13501                _base: base,
13502                ro,
13503                rw,
13504                outside,
13505            }
13506        }
13507
13508        fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
13509            use crate::host_paths::{HostPath, HostPathAccess};
13510            vec![
13511                HostPath {
13512                    declared: self.ro.display().to_string(),
13513                    path: self.ro.clone(),
13514                    access: HostPathAccess::Read,
13515                    credential_locations: Vec::new(),
13516                },
13517                HostPath {
13518                    declared: self.rw.display().to_string(),
13519                    path: self.rw.clone(),
13520                    access: HostPathAccess::ReadWrite,
13521                    credential_locations: Vec::new(),
13522                },
13523            ]
13524        }
13525
13526        /// Four builtin-only commands — read `ro/`, write `ro/`, write `rw/`, read `outside/` — run
13527        /// on `enforcement`, each asserted.
13528        fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
13529            let workdir = tempfile::tempdir().unwrap();
13530            let read = run_bash_in(
13531                workdir.path(),
13532                &format!(
13533                    "read -r line < '{}/in.txt' && echo \"$line\"",
13534                    self.ro.display()
13535                ),
13536                policy,
13537                enforcement,
13538            );
13539            assert_eq!(read.exit_code, 0, "{}", read.stderr);
13540            assert!(
13541                read.stdout.lines().any(|line| line == "lent"),
13542                "{}",
13543                read.stdout
13544            );
13545
13546            let write_ro = run_bash_in(
13547                workdir.path(),
13548                &format!("echo x > '{}/new'", self.ro.display()),
13549                policy,
13550                enforcement,
13551            );
13552            assert_ne!(write_ro.exit_code, 0);
13553            assert!(
13554                write_ro.stderr.contains("Permission denied")
13555                    || write_ro.stderr.contains("Read-only file system"),
13556                "{}",
13557                write_ro.stderr
13558            );
13559            assert!(!self.ro.join("new").exists());
13560
13561            let write_rw = run_bash_in(
13562                workdir.path(),
13563                &format!("echo written > '{}/new'", self.rw.display()),
13564                policy,
13565                enforcement,
13566            );
13567            assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
13568            assert_eq!(
13569                std::fs::read_to_string(self.rw.join("new")).unwrap(),
13570                "written\n"
13571            );
13572
13573            let outside = run_bash_in(
13574                workdir.path(),
13575                &format!("read -r line < '{}/secret.txt'", self.outside.display()),
13576                policy,
13577                enforcement,
13578            );
13579            assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
13580        }
13581    }
13582
13583    /// The enforcement a capsule declaring `class` gets here with `host_paths`, holding a session's
13584    /// directories, or `None` after a skip line when this host does not reach `tier`.
13585    fn host_path_enforcement(
13586        test: &str,
13587        policy: &CapabilityPolicy,
13588        class: murmur_artifact::ContainmentClass,
13589        tier: EnforcementTier,
13590    ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
13591        if crate::network_namespace::skip_without_egress_namespace(test) {
13592            return None;
13593        }
13594        let probe = HostProbe::probe();
13595        let reaches = match tier {
13596            EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
13597            _ => matches!(
13598                probe.tier(),
13599                EnforcementTier::KernelFull | EnforcementTier::KernelSealed
13600            ),
13601        };
13602        if !reaches {
13603            eprintln!(
13604                "SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
13605                probe.tier()
13606            );
13607            return None;
13608        }
13609        let runtime = home_tempdir();
13610        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
13611            .expect("the enforcement resolves")
13612            .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13613        assert_eq!(enforcement.tier, tier);
13614        eprintln!("{test}: tier {tier:?}");
13615        Some((enforcement, runtime))
13616    }
13617
13618    /// [`ShellEnforcement::resolve`] for a capsule declaring `class` on a host of `tier`, from no
13619    /// probe: a resolution test that spawns nothing.
13620    fn resolve_on_tier(
13621        policy: &CapabilityPolicy,
13622        tier: EnforcementTier,
13623        class: murmur_artifact::ContainmentClass,
13624    ) -> Result<ShellEnforcement, String> {
13625        let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
13626        let probe = HostProbe::from_parts(
13627            None,
13628            crate::namespace_probe::NamespaceReading {
13629                network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
13630                    "not probed".to_string(),
13631                )),
13632                mount: crate::namespace_probe::MountAttempt::NotAttempted,
13633                sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
13634                private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
13635                userns_grant: crate::sealed::UsernsGrant::Withheld,
13636                attachment: crate::sealed::ProfileAttachment::NotReported,
13637            },
13638        );
13639        ShellEnforcement::resolve(policy, &decision, probe)
13640    }
13641
13642    #[test]
13643    fn host_paths_on_kernel_full_read_write_and_outside() {
13644        const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
13645        let dirs = HostPathDirs::new();
13646        let policy = CapabilityPolicy {
13647            shell_allow: vec!["bash".to_string()],
13648            host_paths: dirs.host_paths(),
13649            ..CapabilityPolicy::default()
13650        };
13651        let Some((enforcement, _runtime)) = host_path_enforcement(
13652            TEST,
13653            &policy,
13654            murmur_artifact::ContainmentClass::Scoped,
13655            EnforcementTier::KernelFull,
13656        ) else {
13657            return;
13658        };
13659        dirs.assert_reach(&enforcement, &policy);
13660    }
13661
13662    #[test]
13663    fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
13664        const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
13665        let dirs = HostPathDirs::new();
13666        let policy = CapabilityPolicy {
13667            shell_allow: vec!["bash".to_string()],
13668            host_paths: dirs.host_paths(),
13669            ..CapabilityPolicy::default()
13670        };
13671        let Some((enforcement, _runtime)) = host_path_enforcement(
13672            TEST,
13673            &policy,
13674            murmur_artifact::ContainmentClass::Sealed,
13675            EnforcementTier::KernelSealed,
13676        ) else {
13677            return;
13678        };
13679        dirs.assert_reach(&enforcement, &policy);
13680        // Outside is not denied but absent: the composed root does not hold it.
13681        let workdir = tempfile::tempdir().unwrap();
13682        let absent = run_bash_in(
13683            workdir.path(),
13684            &format!(
13685                "[ -e '{}/secret.txt' ] && echo present || echo absent",
13686                dirs.outside.display()
13687            ),
13688            &policy,
13689            &enforcement,
13690        );
13691        assert!(
13692            absent.stdout.lines().any(|line| line == "absent"),
13693            "{}",
13694            absent.stdout
13695        );
13696    }
13697
13698    /// A read-write host path holding a folder the session may run from: refused when resolving
13699    /// a `KernelFull` enforcement, and on `KernelSealed` that folder is bound read-only on top,
13700    /// so the capsule cannot rewrite what it runs while the rest of the entry stays writable.
13701    #[test]
13702    fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
13703        const TEST: &str =
13704            "a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
13705        let base = home_tempdir();
13706        let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
13707        let bin = tools.join("bin");
13708        std::fs::create_dir_all(&bin).unwrap();
13709        std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
13710        let policy = CapabilityPolicy {
13711            shell_allow: vec!["bash".to_string()],
13712            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
13713                binary: "bash".to_string(),
13714                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
13715                    path: bin.display().to_string(),
13716                    list_dir: true,
13717                }],
13718            }],
13719            host_paths: vec![crate::host_paths::HostPath {
13720                declared: tools.display().to_string(),
13721                path: tools.clone(),
13722                access: crate::host_paths::HostPathAccess::ReadWrite,
13723                credential_locations: Vec::new(),
13724            }],
13725            ..CapabilityPolicy::default()
13726        };
13727        let error = resolve_on_tier(
13728            &policy,
13729            EnforcementTier::KernelFull,
13730            murmur_artifact::ContainmentClass::Scoped,
13731        )
13732        .expect_err("scoped refuses a read-write host path over an execute root");
13733        assert!(error.contains(&bin.display().to_string()), "{error}");
13734
13735        let Some((enforcement, _runtime)) = host_path_enforcement(
13736            TEST,
13737            &policy,
13738            murmur_artifact::ContainmentClass::Sealed,
13739            EnforcementTier::KernelSealed,
13740        ) else {
13741            return;
13742        };
13743        assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
13744        let workdir = tempfile::tempdir().unwrap();
13745        let rewrite = run_bash_in(
13746            workdir.path(),
13747            &format!("echo x > '{}/tool'", bin.display()),
13748            &policy,
13749            &enforcement,
13750        );
13751        assert_ne!(rewrite.exit_code, 0);
13752        assert!(
13753            rewrite.stderr.contains("Read-only file system"),
13754            "{}",
13755            rewrite.stderr
13756        );
13757        let add = run_bash_in(
13758            workdir.path(),
13759            &format!("echo x > '{}/new'", bin.display()),
13760            &policy,
13761            &enforcement,
13762        );
13763        assert_ne!(add.exit_code, 0);
13764        assert!(
13765            add.stderr.contains("Read-only file system"),
13766            "{}",
13767            add.stderr
13768        );
13769        assert_eq!(
13770            std::fs::read_to_string(bin.join("tool")).unwrap(),
13771            "#!/bin/bash\necho tool ran\n"
13772        );
13773        assert!(!bin.join("new").exists());
13774        let elsewhere = run_bash_in(
13775            workdir.path(),
13776            &format!("echo kept > '{}/notes'", tools.display()),
13777            &policy,
13778            &enforcement,
13779        );
13780        assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
13781        assert_eq!(
13782            std::fs::read_to_string(tools.join("notes")).unwrap(),
13783            "kept\n"
13784        );
13785    }
13786
13787    /// A host path that can no longer be opened fails the spawn naming it, on both Landlock tiers.
13788    #[test]
13789    fn a_vanished_host_path_fails_the_spawn_naming_it() {
13790        let base = home_tempdir();
13791        let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
13792        std::fs::create_dir(&gone).unwrap();
13793        let host_paths = vec![crate::host_paths::HostPath {
13794            declared: gone.display().to_string(),
13795            path: gone.clone(),
13796            access: crate::host_paths::HostPathAccess::Read,
13797            credential_locations: Vec::new(),
13798        }];
13799        std::fs::remove_dir(&gone).unwrap();
13800        let workdir = tempfile::tempdir().unwrap();
13801        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13802            let enforcement = ShellEnforcement {
13803                tier,
13804                host_paths: host_paths.clone(),
13805                ..host_bounding_base().with_test_session_dirs()
13806            };
13807            let mut command = std::process::Command::new("bash");
13808            let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
13809                panic!("{tier:?}: a vanished host path must fail the spawn");
13810            };
13811            assert!(
13812                error.contains(&gone.display().to_string()),
13813                "{tier:?}: {error}"
13814            );
13815        }
13816    }
13817
13818    /// No host path, no change: no rule, no bind, no base restriction.
13819    #[test]
13820    fn no_host_paths_add_nothing_to_the_enforcement() {
13821        let policy = CapabilityPolicy {
13822            shell_allow: vec!["bash".to_string()],
13823            ..CapabilityPolicy::default()
13824        };
13825        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13826            let class = if tier == EnforcementTier::KernelSealed {
13827                murmur_artifact::ContainmentClass::Sealed
13828            } else {
13829                murmur_artifact::ContainmentClass::Scoped
13830            };
13831            let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
13832            assert_eq!(enforcement.tier, tier);
13833            assert!(enforcement.host_paths.is_empty());
13834            assert!(enforcement.host_path_read_only_binds.is_empty());
13835        }
13836    }
13837}