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.
1052pub(crate) fn 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/// One [`LandlockGrant`] per path a tool's declared program need was granted (see
1258/// [`crate::declared_needs`]): a directory readable and listable, a file readable.
1259///
1260/// `executable: false` on every one (see [`LandlockGrant`]): a need is read, never run, and a
1261/// declared library still loads, because `dlopen(3)` needs `ReadFile` alone. Libraries arrive
1262/// file by file, never as their folder. The derivation gates on the capsule's own declarations
1263/// and never names `/`, `/proc`, `/sys`, `/dev`, the home folder, a capsule-writable tree or an
1264/// `/etc` credential location, or a folder holding one.
1265///
1266/// Consulted only on `KernelFull` and `KernelSealed`, like every grant here.
1267pub(crate) fn resolve_declared_need_grants(
1268    needs: &[crate::declared_needs::ResolvedProgramNeeds],
1269) -> Vec<LandlockGrant> {
1270    needs
1271        .iter()
1272        .flat_map(|program| program.granted.iter())
1273        .flat_map(|need| need.paths.iter())
1274        .map(|entry| LandlockGrant {
1275            path: entry.path.clone(),
1276            list_dir: entry.directory,
1277            executable: false,
1278        })
1279        .collect()
1280}
1281
1282/// Each path a declared program need was granted, as an optional composed-root mirror at its own
1283/// path: a file mirrored as a file, a directory as a directory, never the folder holding it.
1284///
1285/// Skipped when the composed root already carries it: under a fixed
1286/// [`crate::sealed::SEALED_RUNTIME_PATHS`] tree, at or under a [`crate::sealed::SEALED_ETC_PATHS`]
1287/// entry, or under one of `bind_dirs` ([`resolve_sealed_bind_dirs`]). Deduplicated. Pure and
1288/// syscall-free.
1289pub(crate) fn resolve_sealed_need_mirrors(
1290    needs: &[crate::declared_needs::ResolvedProgramNeeds],
1291    bind_dirs: &[PathBuf],
1292) -> Vec<PathBuf> {
1293    let mut mirrors: Vec<PathBuf> = Vec::new();
1294    for path in needs
1295        .iter()
1296        .flat_map(|program| program.granted.iter())
1297        .flat_map(|need| need.paths.iter())
1298        .map(|entry| &entry.path)
1299    {
1300        let carried = crate::sealed::SEALED_RUNTIME_PATHS
1301            .iter()
1302            .chain(
1303                crate::sealed::SEALED_ETC_PATHS
1304                    .iter()
1305                    .map(|entry| &entry.path),
1306            )
1307            .any(|root| path.starts_with(root))
1308            || bind_dirs.iter().any(|dir| path.starts_with(dir));
1309        if path.is_absolute() && !carried && !mirrors.contains(path) {
1310            mirrors.push(path.clone());
1311        }
1312    }
1313    mirrors
1314}
1315
1316/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
1317/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
1318/// allowlisted binary, each declared `interpreter_runtime` directory, and each directory a
1319/// recognised interpreter reported on its import path with the parent directory of each library
1320/// its extension modules need.
1321///
1322/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
1323/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
1324/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
1325/// derivation `scoped` needs has nothing left to do here.
1326///
1327/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
1328/// makes the composed root narrower rather than refusing the launch, which is why
1329/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
1330/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
1331/// required bind. This function is additive to that, not replaced by it.
1332///
1333/// Pure and syscall-free, so it is unit-testable on any platform.
1334pub(crate) fn resolve_sealed_bind_dirs(
1335    exec_allow_paths: &[PathBuf],
1336    policy: &CapabilityPolicy,
1337) -> Vec<PathBuf> {
1338    let mut dirs: Vec<PathBuf> = Vec::new();
1339    let candidates = exec_allow_paths
1340        .iter()
1341        .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1342        .chain(
1343            policy
1344                .shell_interpreter_runtime
1345                .iter()
1346                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1347        )
1348        .chain(
1349            policy
1350                .shell_derived_interpreter_runtime
1351                .iter()
1352                .flat_map(|runtime| {
1353                    runtime
1354                        .entries()
1355                        .filter(|(_, is_dir)| *is_dir)
1356                        .map(|(path, _)| path.to_path_buf())
1357                        .chain(
1358                            runtime
1359                                .libraries
1360                                .iter()
1361                                .filter_map(|library| library.parent().map(Path::to_path_buf)),
1362                        )
1363                        .collect::<Vec<_>>()
1364                }),
1365        );
1366
1367    for candidate in candidates {
1368        push_sealed_bind_dir(&mut dirs, candidate);
1369    }
1370    dirs
1371}
1372
1373/// Every directory this session may run something out of, for the host-path check
1374/// ([`crate::host_paths::check_execute_roots`]): the directory of each file its Landlock grants
1375/// mark executable (each resolved `shell.allow` binary, its ELF interpreter and `DT_NEEDED`
1376/// closure) and each executable directory grant (`interpreter_runtime` directories,
1377/// `staged_runtime` trees). Called by `stage_session` and `mur run --explain-scope`.
1378pub(crate) fn host_path_execute_roots(
1379    policy: &CapabilityPolicy,
1380) -> Vec<crate::host_paths::ExecuteRoot> {
1381    let exec_allow_paths = resolve_policy_exec_allowlist(policy);
1382    execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
1383}
1384
1385/// [`host_path_execute_roots`] from an already-resolved executable closure.
1386fn execute_roots_from(
1387    closure: &[PathBuf],
1388    policy: &CapabilityPolicy,
1389) -> Vec<crate::host_paths::ExecuteRoot> {
1390    let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
1391    let mut push = |dir: PathBuf, because: PathBuf| {
1392        let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
1393        if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
1394            roots.push(crate::host_paths::ExecuteRoot { dir, because });
1395        }
1396    };
1397    for file in closure {
1398        if let Some(parent) = file.parent() {
1399            push(parent.to_path_buf(), file.clone());
1400        }
1401    }
1402    for grant in &policy.shell_interpreter_runtime {
1403        for dir in &grant.dirs {
1404            push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
1405        }
1406    }
1407    for dir in resolve_staged_runtime_dirs(policy) {
1408        push(dir.clone(), dir);
1409    }
1410    for dir in resolve_pinned_program_dirs(policy) {
1411        push(dir.clone(), dir);
1412    }
1413    roots
1414}
1415
1416/// The canonical binaries `policy`'s `shell.allow` names: every match on this process's `PATH`
1417/// ([`resolve_exec_allowlist`]), then every match in the `bin/` folder of each program staging
1418/// chose the store's pinned copy of ([`crate::pinned_programs`]) — the folders every command's
1419/// own `PATH` ends with, so the allowlist and what `execvp` can find agree.
1420pub(crate) fn resolve_policy_exec_allowlist(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1421    let mut resolved: std::collections::BTreeSet<PathBuf> =
1422        resolve_exec_allowlist(&policy.shell_allow)
1423            .into_iter()
1424            .collect();
1425    resolved.extend(resolve_exec_allowlist_in(
1426        &policy.shell_allow,
1427        &crate::pinned_programs::pinned_bin_dirs(&policy.shell_pinned_programs),
1428    ));
1429    resolved.into_iter().collect()
1430}
1431
1432/// The store entry, `~/.murmur/programs/<sha256>`, of each program staging chose the pinned copy
1433/// of: one listable, executable Landlock grant each ([`resolve_pinned_program_landlock_grants`])
1434/// and, on `sealed`, one optional read-only bind at its own path. One list feeds both, so the
1435/// grant and the bind cannot drift. A `host` or `missing` choice contributes nothing.
1436///
1437/// Pure and syscall-free.
1438pub(crate) fn resolve_pinned_program_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1439    crate::pinned_programs::pinned_trees(&policy.shell_pinned_programs)
1440        .into_iter()
1441        .filter(|tree| tree.is_absolute())
1442        .collect()
1443}
1444
1445/// One listable, executable [`LandlockGrant`] per [`resolve_pinned_program_dirs`] entry. Read and
1446/// execute only, never write: a capsule can run the store's copy and never change it.
1447pub(crate) fn resolve_pinned_program_landlock_grants(
1448    policy: &CapabilityPolicy,
1449) -> Vec<LandlockGrant> {
1450    resolve_pinned_program_dirs(policy)
1451        .into_iter()
1452        .map(|path| LandlockGrant {
1453            path,
1454            list_dir: true,
1455            executable: true,
1456        })
1457        .collect()
1458}
1459
1460/// What `tier` does with a host path.
1461pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
1462    match tier {
1463        EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
1464        EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
1465        EnforcementTier::KernelSeccompOnly
1466        | EnforcementTier::EnvironmentOnly
1467        | EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
1468    }
1469}
1470
1471/// Adds `candidate` to `dirs` as an optional composed-root bind unless it is relative, already
1472/// inside a fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, or already listed.
1473fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1474    if !candidate.is_absolute() {
1475        return;
1476    }
1477    if crate::sealed::SEALED_RUNTIME_PATHS
1478        .iter()
1479        .any(|root| candidate.starts_with(root))
1480    {
1481        return;
1482    }
1483    if dirs.contains(&candidate) {
1484        return;
1485    }
1486    dirs.push(candidate);
1487}
1488
1489/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
1490/// root's *required* read-only binds.
1491///
1492/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
1493/// way that function filters. Both differences follow from the same fact — these fail closed:
1494///
1495///   * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
1496///     Dropping it would be sound for an optional bind (the fixed list already covers it) but it
1497///     would quietly convert a required grant into a dependency on an unrelated list staying the
1498///     way it is today. `plan_composed_root` runs the required loop first and deduplicates by
1499///     target, so naming an already-covered path costs one registration and stays required.
1500///   * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
1501///     grant naming a tree this host does not have refuses the launch — see
1502///     `sealed::PlanBuilder::require_bind`.
1503///
1504/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
1505/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
1506/// layer that rejects one.
1507///
1508/// Pure and syscall-free, so it is unit-testable on any platform.
1509pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1510    let mut dirs: Vec<PathBuf> = Vec::new();
1511    for grant in &policy.shell_staged_runtime {
1512        let path = PathBuf::from(&grant.source_path);
1513        if !path.is_absolute() || dirs.contains(&path) {
1514            continue;
1515        }
1516        dirs.push(path);
1517    }
1518    dirs
1519}
1520
1521/// One listable [`LandlockGrant`] per staged runtime directory.
1522///
1523/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
1524/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
1525/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
1526/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
1527/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
1528/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
1529/// working at all.
1530///
1531/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
1532/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
1533/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
1534/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
1535/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
1536/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
1537/// same inode reached through the bind inside the root.
1538///
1539/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
1540/// of two independent reasons the tree cannot be mutated from inside a session.
1541///
1542/// Pure and syscall-free, so it is unit-testable on any platform.
1543pub(crate) fn resolve_staged_runtime_landlock_grants(
1544    policy: &CapabilityPolicy,
1545) -> Vec<LandlockGrant> {
1546    resolve_staged_runtime_dirs(policy)
1547        .into_iter()
1548        .map(|path| LandlockGrant {
1549            path,
1550            list_dir: true,
1551            executable: true,
1552        })
1553        .collect()
1554}
1555
1556/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1557/// `KernelSealed`.
1558///
1559/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1560/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1561/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1562/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1563/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1564/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1565/// enumerable.
1566///
1567/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1568/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1569///
1570/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1571/// the manifest never named, so it must enumerate them without becoming permission to run them.
1572///
1573/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1574/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1575/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1576/// nothing. Empty on every other tier.
1577///
1578/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1579/// to open), so unit-testable on every platform.
1580pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1581    if tier != EnforcementTier::KernelSealed {
1582        return Vec::new();
1583    }
1584    crate::sealed::SEALED_RUNTIME_PATHS
1585        .iter()
1586        .map(|path| LandlockGrant {
1587            path: PathBuf::from(path),
1588            list_dir: true,
1589            executable: false,
1590        })
1591        .collect()
1592}
1593
1594/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1595/// `KernelSealed`.
1596///
1597/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1598/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1599/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1600/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1601/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1602/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1603/// fail certificate verification over an otherwise-working TCP connection.
1604///
1605/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1606/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1607/// directories, regular files and symlinks.
1608/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1609/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1610/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1611///
1612/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1613/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1614/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1615/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1616/// prevent.
1617///
1618/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1619/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1620///
1621/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1622/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1623/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1624/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1625/// reading it reveals nothing sensitive. Empty on every other tier.
1626/// The trust store alone is granted on every Landlock tier, under the network gate, by
1627/// [`resolve_trust_store_grants`].
1628///
1629/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1630/// open), so unit-testable on every platform.
1631pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1632    if tier != EnforcementTier::KernelSealed {
1633        return Vec::new();
1634    }
1635    crate::sealed::SEALED_ETC_PATHS
1636        .iter()
1637        .map(|entry| LandlockGrant {
1638            path: PathBuf::from(entry.path),
1639            list_dir: entry.directory,
1640            executable: false,
1641        })
1642        .collect()
1643}
1644
1645/// The host files a `scoped` command reads to resolve `localhost` and the other names
1646/// `/etc/hosts` carries: the resolver's configuration and the hosts table. Each is also in
1647/// [`crate::sealed::SEALED_ETC_PATHS`], which grants them on `sealed`.
1648pub(crate) const SCOPED_NAME_RESOLUTION_PATHS: &[&str] = &["/etc/hosts", "/etc/nsswitch.conf"];
1649
1650/// One `ReadFile`-only grant per [`SCOPED_NAME_RESOLUTION_PATHS`] entry on `KernelFull`, and
1651/// nothing on any other tier: `KernelSealed` grants them through
1652/// [`resolve_sealed_etc_landlock_grants`], and the tiers without Landlock grant nothing.
1653///
1654/// Not gated on `network.allow`: `localhost` resolves without a network, and a server a command
1655/// starts on `localhost` is reached by name. Neither file names a secret — `/etc/resolv.conf`,
1656/// `/etc/passwd` and the rest of `/etc` stay denied on `scoped` — and neither is ever writable.
1657///
1658/// Pure and syscall-free, like the resolvers above.
1659pub(crate) fn resolve_scoped_name_resolution_landlock_grants(
1660    tier: EnforcementTier,
1661) -> Vec<LandlockGrant> {
1662    if tier != EnforcementTier::KernelFull {
1663        return Vec::new();
1664    }
1665    SCOPED_NAME_RESOLUTION_PATHS
1666        .iter()
1667        .map(|path| LandlockGrant {
1668            path: PathBuf::from(path),
1669            list_dir: false,
1670            executable: false,
1671        })
1672        .collect()
1673}
1674
1675// ---- fixed capsule device set ------------------------------------------------------------
1676//
1677// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1678// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1679// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1680// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1681// device, and none that takes one away.
1682//
1683// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1684// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1685// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1686// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1687
1688/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1689///
1690/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1691/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1692/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1693/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1694/// `list_dir` into a third meaning.
1695///
1696/// `writable` is the whole reason this type exists as more than a path list:
1697///
1698///   - `false` → `ReadFile` only. The device is readable and nothing more.
1699///   - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, which with the
1700///     session's temporary folder is one of the two writable paths outside the workdir.
1701///
1702/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1703/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1704/// module's Linux-only surface carries.
1705#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1707pub(crate) struct CapsuleDeviceGrant {
1708    pub(crate) path: &'static str,
1709    pub(crate) writable: bool,
1710}
1711
1712/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1713/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1714/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1715/// stays denied by the same mechanism that already denies every other unlisted path: with
1716/// `handle_access` declaring the full right-set of the ABI the ruleset is built at, "no rule" means
1717/// "denied", not "ungoverned".
1718///
1719///   - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1720///     outside the workdir is writable". A read-only grant would be broken, not merely strict:
1721///     bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1722///     shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1723///     and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1724///     write on a character device whose write side is defined to discard everything gives up no
1725///     confidentiality and no integrity — there is no state behind it to reach.
1726///     (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1727///     file is actually created, and `/dev/null` already exists.)
1728///   - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1729///     in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1730///     write there, so it does not get `WriteFile`.
1731///   - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1732///     filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1733///     device outright and fall over if they cannot.
1734///
1735/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1736/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1737/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1738/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1739/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1740/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1741///
1742/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1743/// workload has been observed failing on the missing device, and record that failure in the same
1744/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1745///
1746/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1747/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1748/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1749/// implementation should *retire* this constant, not merge with it.
1750#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1751pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1752    CapsuleDeviceGrant {
1753        path: "/dev/null",
1754        writable: true,
1755    },
1756    CapsuleDeviceGrant {
1757        path: "/dev/zero",
1758        writable: false,
1759    },
1760    CapsuleDeviceGrant {
1761        path: "/dev/urandom",
1762        writable: false,
1763    },
1764];
1765
1766/// Network decision for one destination IP.
1767/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1768/// no reason to open any subprocess socket).
1769#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1770pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1771    network_allow_ips.contains(&ip)
1772}
1773
1774// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1775// rather than `libc::AF_*`: the filter these feed is
1776// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1777// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1778// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1779// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1780// of the three from `libc` and two from literals would be worse than taking all three the same
1781// way. Values are stable kernel ABI: `include/linux/socket.h`.
1782const LINUX_AF_UNIX: i32 = 1;
1783const LINUX_AF_INET: i32 = 2;
1784const LINUX_AF_INET6: i32 = 10;
1785const LINUX_AF_NETLINK: i32 = 16;
1786const LINUX_AF_PACKET: i32 = 17;
1787
1788/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1789/// `capabilities.network.unix_sockets` declaration.
1790///
1791/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1792/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1793/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1794///
1795/// Why a domain check is a classic BPF rule and not a notify:
1796/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1797/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1798/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1799/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1800/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1801/// pointed-to argument out of another task's memory invites.
1802///
1803/// The three families, and why they are not symmetric:
1804///   - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1805///     by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1806///     need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1807///     Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1808///   - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1809///     capture and injection) are denied unconditionally, with no manifest key to widen them.
1810///     Neither has a shell-tool use case comparable to a local daemon socket, and both are
1811///     strictly more dangerous than one. Widening them would be a deliberate future decision with
1812///     its own justification, not an oversight to be fixed by adding a flag here.
1813///
1814/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1815/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1816/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1817/// get a positive rule — since the filter's default action is a deny, a family named by neither
1818/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1819#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1820pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1821    let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1822    if !unix_sockets_allowed {
1823        denied.push(LINUX_AF_UNIX);
1824    }
1825    denied
1826}
1827
1828/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1829/// `capabilities.network.unix_sockets` declaration. The exact complement of
1830/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1831///
1832/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1833/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1834/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1835/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1836/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1837/// equality-matched sets that a test can check against each other.
1838///
1839/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1840/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1841/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1842/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1843/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1844/// reason) if a real workload needs it.
1845#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1846pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1847    let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1848    if unix_sockets_allowed {
1849        allowed.push(LINUX_AF_UNIX);
1850    }
1851    allowed
1852}
1853
1854/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1855/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1856///
1857/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1858/// reason: the filter is always compiled for a Linux child.
1859#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1860const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1861#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1862const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1863
1864/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1865/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1866/// is the maintained source of the same list Docker ships).
1867///
1868/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1869/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1870///
1871///   - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1872///     [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1873///
1874/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1875/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1876/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1877/// child's very first act after this filter loads is the `execve` that turns it into the tool
1878/// binary, so a denial here refuses every capsule outright.
1879///
1880/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1881/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1882/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1883/// `io_uring` is the one that
1884/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1885/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1886/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1887///
1888/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1889/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1890/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1891/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1892/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1893/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1894#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1895pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1896    // ---- process / thread lifecycle ----
1897    // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1898    // doc comment for the mechanism that replaced their former `Notify` rules.
1899    "execve",
1900    "execveat",
1901    // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1902    // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1903    // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1904    // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1905    // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1906    // largely inert.
1907    "clone",
1908    "clone3",
1909    "fork",
1910    "vfork",
1911    "exit",
1912    "exit_group",
1913    "wait4",
1914    "waitid",
1915    "kill",
1916    "tkill",
1917    "tgkill",
1918    "rt_sigaction",
1919    "rt_sigprocmask",
1920    "rt_sigreturn",
1921    "rt_sigsuspend",
1922    "rt_sigpending",
1923    "rt_sigtimedwait",
1924    "rt_sigqueueinfo",
1925    "rt_tgsigqueueinfo",
1926    "sigaltstack",
1927    // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1928    // would break every `sleep`/`read` that takes a signal.
1929    "restart_syscall",
1930    "pause",
1931    "set_tid_address",
1932    "set_robust_list",
1933    "get_robust_list",
1934    // glibc 2.35+ registers a restartable sequence on every thread start.
1935    "rseq",
1936    "prctl",
1937    "arch_prctl",
1938    "getpid",
1939    "getppid",
1940    "gettid",
1941    "getpgrp",
1942    "getpgid",
1943    "setpgid",
1944    "getsid",
1945    "setsid",
1946    "getpriority",
1947    "setpriority",
1948    "ioprio_get",
1949    "ioprio_set",
1950    "sched_yield",
1951    "sched_getaffinity",
1952    "sched_setaffinity",
1953    "sched_getparam",
1954    "sched_setparam",
1955    "sched_getscheduler",
1956    "sched_setscheduler",
1957    "sched_getattr",
1958    "sched_setattr",
1959    "sched_get_priority_max",
1960    "sched_get_priority_min",
1961    "sched_rr_get_interval",
1962    "getcpu",
1963    "capget",
1964    "capset",
1965    "prlimit64",
1966    "getrlimit",
1967    "setrlimit",
1968    "getrusage",
1969    "getitimer",
1970    "setitimer",
1971    "times",
1972    "uname",
1973    "sysinfo",
1974    // pidfd process handles: they only ever address a process the caller could already signal.
1975    "pidfd_open",
1976    "pidfd_send_signal",
1977    // ---- memory ----
1978    "mmap",
1979    "munmap",
1980    "mprotect",
1981    "mremap",
1982    "madvise",
1983    "brk",
1984    "mlock",
1985    "mlock2",
1986    "munlock",
1987    "mlockall",
1988    "munlockall",
1989    "membarrier",
1990    "msync",
1991    "mincore",
1992    "memfd_create",
1993    "pkey_alloc",
1994    "pkey_free",
1995    "pkey_mprotect",
1996    "shmget",
1997    "shmat",
1998    "shmdt",
1999    "shmctl",
2000    // ---- file I/O ----
2001    "open",
2002    "openat",
2003    "openat2",
2004    "creat",
2005    "close",
2006    "close_range",
2007    "read",
2008    "write",
2009    "pread64",
2010    "pwrite64",
2011    "readv",
2012    "writev",
2013    "preadv",
2014    "pwritev",
2015    "preadv2",
2016    "pwritev2",
2017    "lseek",
2018    "dup",
2019    "dup2",
2020    "dup3",
2021    "fcntl",
2022    "flock",
2023    "fsync",
2024    "fdatasync",
2025    "sync",
2026    "syncfs",
2027    "sync_file_range",
2028    "fadvise64",
2029    "readahead",
2030    "truncate",
2031    "ftruncate",
2032    "fallocate",
2033    "stat",
2034    "fstat",
2035    "lstat",
2036    "newfstatat",
2037    "statx",
2038    "access",
2039    "faccessat",
2040    "faccessat2",
2041    "getdents",
2042    "getdents64",
2043    "getcwd",
2044    "chdir",
2045    "fchdir",
2046    "mkdir",
2047    "mkdirat",
2048    "rmdir",
2049    "unlink",
2050    "unlinkat",
2051    "rename",
2052    "renameat",
2053    "renameat2",
2054    "link",
2055    "linkat",
2056    "symlink",
2057    "symlinkat",
2058    "readlink",
2059    "readlinkat",
2060    "chmod",
2061    "fchmod",
2062    "fchmodat",
2063    // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
2064    // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
2065    "fchmodat2",
2066    "chown",
2067    "fchown",
2068    "fchownat",
2069    "lchown",
2070    "umask",
2071    "utime",
2072    "utimes",
2073    "utimensat",
2074    "futimesat",
2075    // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
2076    // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
2077    // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
2078    "mknod",
2079    "mknodat",
2080    "sendfile",
2081    "copy_file_range",
2082    "splice",
2083    "tee",
2084    "vmsplice",
2085    "ioctl",
2086    "poll",
2087    "ppoll",
2088    "select",
2089    "pselect6",
2090    "epoll_create",
2091    "epoll_create1",
2092    "epoll_ctl",
2093    "epoll_wait",
2094    "epoll_pwait",
2095    "epoll_pwait2",
2096    "eventfd",
2097    "eventfd2",
2098    "pipe",
2099    "pipe2",
2100    "inotify_init",
2101    "inotify_init1",
2102    "inotify_add_watch",
2103    "inotify_rm_watch",
2104    "signalfd",
2105    "signalfd4",
2106    "timerfd_create",
2107    "timerfd_settime",
2108    "timerfd_gettime",
2109    "getxattr",
2110    "lgetxattr",
2111    "fgetxattr",
2112    "setxattr",
2113    "lsetxattr",
2114    "fsetxattr",
2115    "listxattr",
2116    "llistxattr",
2117    "flistxattr",
2118    "removexattr",
2119    "lremovexattr",
2120    "fremovexattr",
2121    "statfs",
2122    "fstatfs",
2123    // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
2124    // into an open file, ignoring the path it was reached by) stays denied.
2125    "name_to_handle_at",
2126    // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
2127    // path resolution of its own, so it carries none of the LSM-bypass property that got the
2128    // `io_uring_*` family denied.
2129    "io_setup",
2130    "io_destroy",
2131    "io_submit",
2132    "io_cancel",
2133    "io_getevents",
2134    // ---- sockets ----
2135    // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
2136    //
2137    // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
2138    // capsule's own network namespace has no route to anything except the egress proxy. The
2139    // destination check lives there, not in this filter — see `crate::network_namespace`.
2140    "connect",
2141    "sendto",
2142    "socketpair",
2143    "bind",
2144    "listen",
2145    "accept",
2146    "accept4",
2147    "getsockname",
2148    "getpeername",
2149    "getsockopt",
2150    "setsockopt",
2151    "shutdown",
2152    "sendmsg",
2153    "sendmmsg",
2154    "recvfrom",
2155    "recvmsg",
2156    "recvmmsg",
2157    // ---- time ----
2158    "clock_gettime",
2159    "clock_getres",
2160    "clock_nanosleep",
2161    "nanosleep",
2162    "gettimeofday",
2163    "time",
2164    "timer_create",
2165    "timer_settime",
2166    "timer_gettime",
2167    "timer_getoverrun",
2168    "timer_delete",
2169    "alarm",
2170    // ---- System V / POSIX IPC ----
2171    "msgget",
2172    "msgsnd",
2173    "msgrcv",
2174    "msgctl",
2175    "semget",
2176    "semop",
2177    "semctl",
2178    "semtimedop",
2179    "mq_open",
2180    "mq_unlink",
2181    "mq_timedsend",
2182    "mq_timedreceive",
2183    "mq_notify",
2184    "mq_getsetattr",
2185    // ---- misc ----
2186    "getrandom",
2187    "futex",
2188    "futex_waitv",
2189    "getuid",
2190    "geteuid",
2191    "getgid",
2192    "getegid",
2193    "setuid",
2194    "setgid",
2195    "setreuid",
2196    "setregid",
2197    "setresuid",
2198    "setresgid",
2199    "getresuid",
2200    "getresgid",
2201    "getgroups",
2202    "setgroups",
2203    "setfsuid",
2204    "setfsgid",
2205];
2206
2207/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
2208/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
2209/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
2210/// allowlist while reconciling it against a newer upstream profile.
2211///
2212/// Three groups, and they are not equally negotiable:
2213///
2214///   1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
2215///      them: `io_uring_setup`/`io_uring_enter`/
2216///      `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
2217///      around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
2218///      `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
2219///      (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
2220///      `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
2221///      `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
2222///      deliberately stricter there.
2223///   2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
2224///      mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
2225///      clock setting, NUMA policy, `fanotify`.
2226///   3. **Denied instead of argument-conditioned.** Docker allows these only for specific
2227///      argument values, which a per-syscall-name allowlist cannot express and which would
2228///      otherwise have to be routed through a userspace decision this filter no longer makes.
2229///      Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
2230///      standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
2231///      itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
2232// The list is a specification, not an input to the filter build: its one consumer is the
2233// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
2234// the thing that test checks against.
2235#[allow(dead_code)]
2236pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2237    // 1. demonstrated reachable on bare metal
2238    "io_uring_setup",
2239    "io_uring_enter",
2240    "io_uring_register",
2241    "bpf",
2242    "userfaultfd",
2243    "perf_event_open",
2244    "process_vm_readv",
2245    "process_vm_writev",
2246    "open_by_handle_at",
2247    "keyctl",
2248    "add_key",
2249    "request_key",
2250    "ptrace",
2251    // 2. privileged / host-state
2252    "mount",
2253    "umount2",
2254    "pivot_root",
2255    "chroot",
2256    "unshare",
2257    "setns",
2258    "init_module",
2259    "finit_module",
2260    "delete_module",
2261    "kexec_load",
2262    "kexec_file_load",
2263    "reboot",
2264    "swapon",
2265    "swapoff",
2266    "acct",
2267    "quotactl",
2268    "nfsservctl",
2269    "iopl",
2270    "ioperm",
2271    "vm86",
2272    "vm86old",
2273    "_sysctl",
2274    "sysfs",
2275    "uselib",
2276    "create_module",
2277    "get_kernel_syms",
2278    "query_module",
2279    "lookup_dcookie",
2280    "clock_adjtime",
2281    "clock_settime",
2282    "settimeofday",
2283    "stime",
2284    "adjtimex",
2285    "set_mempolicy",
2286    "get_mempolicy",
2287    "mbind",
2288    "migrate_pages",
2289    "move_pages",
2290    "fanotify_init",
2291    "fanotify_mark",
2292    // 3. denied instead of argument-conditioned
2293    "seccomp",
2294    "personality",
2295    "kcmp",
2296];
2297
2298/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
2299/// project treats the filter as having outgrown its headroom. Held against the *measured* export
2300/// of the real filter by a test, not against an estimate: growing
2301/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
2302/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
2303///
2304/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
2305/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
2306/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
2307/// number was chosen against.
2308#[allow(dead_code)]
2309pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2310
2311/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
2312/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
2313/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
2314/// two filters that straddle it in forked children.
2315#[allow(dead_code)]
2316pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2317
2318/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
2319/// attached to one task may total, counting every filter it already carries plus a small
2320/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
2321/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
2322/// quoted: a test stacks filters in a forked child until the refusal arrives.
2323#[allow(dead_code)]
2324pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2325
2326/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
2327#[derive(Debug, Clone)]
2328pub(crate) struct ShellEnforcement {
2329    pub(crate) tier: EnforcementTier,
2330    /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
2331    /// by [`resolve_network_allowlist_ips`].
2332    ///
2333    /// The narrower of the egress proxy's two checks — the one applied to a destination the
2334    /// capsule reached without ever resolving a name for it (a hardcoded literal). See
2335    /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
2336    /// [`network_ip_allowed`].
2337    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2338    pub(crate) network_allow_ips: Vec<IpAddr>,
2339    /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
2340    /// checks a name when it resolves it and again when the connection to the address that name
2341    /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
2342    /// many tenants) in either direction.
2343    ///
2344    /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
2345    /// entry cannot mean one thing to a WASM guest and another to a subprocess.
2346    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2347    pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2348    /// The TCP ports the capsule's network namespace opens listeners on, derived from the
2349    /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
2350    /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
2351    /// consulted — the strongest form the refusal can take.
2352    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2353    pub(crate) egress_tcp_ports: Vec<u16>,
2354    /// The held-port map ([`crate::held_ports::HeldPorts`] over [`Self::egress_tcp_ports`]) when
2355    /// this session's commands are observed for a bind on a held port, `None` when they are not.
2356    ///
2357    /// Decided once, in [`Self::resolve`]: `Some` exactly when the tier is `KernelFull` or
2358    /// `KernelSealed`, the allowlist holds at least one TCP port, and this runtime's libseccomp
2359    /// supports user notification. Read through [`Self::observed_held_ports`], which keys it on
2360    /// [`Self::tier`] again.
2361    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2362    pub(crate) bind_observation: Option<crate::held_ports::HeldPorts>,
2363    /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
2364    /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
2365    /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
2366    /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
2367    /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
2368    /// platform for parity but never read off Linux.
2369    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2370    pub(crate) unix_sockets_allowed: bool,
2371    /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
2372    /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
2373    /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
2374    /// writes into its workdir can run, under any name. Consumed only by
2375    /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
2376    /// for parity but never read off Linux.
2377    ///
2378    /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
2379    /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
2380    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2381    pub(crate) workdir_exec: bool,
2382    /// The Landlock ABI the child's ruleset is built at: [`ruleset_landlock_abi`] of the session's
2383    /// [`HostProbe`] and [`Self::tier`], decided here so the ABI `session_start` records is the one
2384    /// installed. `None` on the tiers with no Landlock domain, and off Linux always.
2385    ///
2386    /// On `KernelFull`/`KernelSealed` a `None` builds at ABI v1, the narrowest set, so a missing
2387    /// reading can only narrow what the capsule may do. `Some(1)` likewise reproduces exactly
2388    /// what a 5.13–5.18 kernel installs, which is how the tests force that ruleset on a newer one.
2389    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2390    pub(crate) landlock_abi: Option<u8>,
2391    /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
2392    /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
2393    /// stdlib. Seven origins, combined here:
2394    ///
2395    ///   - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
2396    ///     shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
2397    ///     (they are individual files, where `ReadDir` was always a no-op);
2398    ///   - one grant per `capabilities.shell.interpreter_runtime` directory, from
2399    ///     `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
2400    ///     declared;
2401    ///   - one non-executable grant per entry a recognised interpreter reported on its import
2402    ///     path at staging, and per library its extension modules need, from
2403    ///     `resolve_derived_interpreter_grants` — listable for a directory;
2404    ///   - one non-executable read grant per host trust-store path staging found for a capsule
2405    ///     with a non-empty `shell.allow` and `network.allow`, from `resolve_trust_store_grants` —
2406    ///     listable for a directory;
2407    ///   - one listable grant per `capabilities.shell.staged_runtime` tree, from
2408    ///     `resolve_staged_runtime_landlock_grants`;
2409    ///   - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
2410    ///     [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
2411    ///     `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
2412    ///     tree, which nothing else grants;
2413    ///   - on `KernelSealed` **only**, one non-executable read grant per fixed
2414    ///     [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
2415    ///     (`list_dir` per entry, since that list mixes directories and files) — the composed
2416    ///     root's `/etc` allowlist, likewise granted by nothing else.
2417    ///
2418    /// The closure, `interpreter_runtime` and `staged_runtime` grants carry `executable: true`;
2419    /// the derived interpreter grants, the trust-store grants and the two tier-issued ones do not,
2420    /// and that asymmetry is load-bearing — see [`LandlockGrant`].
2421    ///
2422    /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
2423    /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
2424    /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
2425    /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
2426    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2427    pub(crate) landlock_grants: Vec<LandlockGrant>,
2428    /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
2429    /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
2430    /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
2431    ///
2432    /// Whole directories, never files: a bind carries a directory's whole contents, so this must
2433    /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
2434    /// every platform for parity, exactly like `landlock_grants` above.
2435    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2436    pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2437    /// Each path a declared program need was granted ([`resolve_sealed_need_mirrors`]), mirrored
2438    /// read-only into the composed root at its own path, file or directory. Optional, like
2439    /// `sealed_bind_dirs`; kept apart from it because a file need's folder (`/etc` for
2440    /// `/etc/fonts/fonts.conf`) must never be bound whole. Consumed only on `KernelSealed`.
2441    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2442    pub(crate) sealed_need_mirrors: Vec<PathBuf>,
2443    /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
2444    /// read-only into the composed root at its own absolute path.
2445    ///
2446    /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
2447    /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
2448    /// while a staged tree that cannot be mounted aborts the composed-root construction before
2449    /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
2450    /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
2451    /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
2452    /// parity, exactly like the two fields above.
2453    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2454    pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2455    /// The store entry of each pinned program the session runs ([`resolve_pinned_program_dirs`]),
2456    /// bound read-only at its own path into the composed root, ahead of the other optional binds.
2457    /// Optional: an entry that vanished since staging makes the root narrower, never refuses.
2458    /// Consumed only on `KernelSealed`.
2459    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2460    pub(crate) pinned_program_dirs: Vec<PathBuf>,
2461    /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
2462    /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
2463    /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
2464    /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
2465    /// half. See [`crate::resources`].
2466    pub(crate) resource_limits: crate::resources::HostResourceLimits,
2467    /// The manifest's own `capabilities.resources` block, every field as declared. The
2468    /// [`EnforcementTier::Unconfined`] tier sets an rlimit only for a field declared here, where
2469    /// every other tier sets [`Self::resource_limits`].
2470    pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
2471    /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
2472    /// for capsules that can actually spawn a subprocess). `None` on macOS always, and on
2473    /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
2474    /// spawn one and could not be given a scope never reaches here, because the launch is
2475    /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
2476    ///
2477    /// Its presence decides each spawn's `RLIMIT_NPROC` ([`Self::nproc_bound`]): a spawn that
2478    /// joins it is bounded by `pids.max` alone.
2479    pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2480    /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
2481    /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
2482    /// only at the next agent turn.
2483    pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2484    /// The session's one network namespace, which every command on a kernel tier joins.
2485    ///
2486    /// Shared by every clone of this enforcement, so a listener one command starts is reachable
2487    /// from the next. A fresh, not-yet-created namespace from [`Self::resolve`] and
2488    /// [`Self::environment_only`]; a plan inside a session takes its session's through
2489    /// [`Self::with_session_netns`]. Released by [`Self::end_session`].
2490    pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2491    /// The session's runtime directory and its `sealed-etc/` sibling: `$MURMUR_RUNTIME_DIR` in
2492    /// every command's environment, a read-only Landlock rule on the Landlock tiers, a read-only
2493    /// bind on `KernelSealed`, and the source of the composed root's `/etc/passwd` and
2494    /// `/etc/group`. `None` outside a session, where a command is told of no runtime directory
2495    /// and a sealed root cannot be composed.
2496    pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2497    /// The session's runtime directory itself, removed by [`Self::remove_runtime_dir`] on the
2498    /// process exits that run no destructor. Set only on the session's own enforcement.
2499    pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2500    /// How a literal `/tmp` reaches the session's temporary folder (`runtime_dir`'s `temp`):
2501    /// the session's [`crate::temp_dir::temp_mapping`] answer, through [`Self::with_temp_mapping`],
2502    /// or [`crate::temp_dir::tier_temp_mapping`] of [`Self::tier`] until one is handed in. Read
2503    /// only while the enforcement holds a folder.
2504    pub(crate) temp_mapping: crate::temp_dir::TempMapping,
2505    /// Which `/proc` this enforcement's commands see: [`crate::proc_view::proc_view`] of the
2506    /// probe [`Self::resolve`] was handed, or one handed in through [`Self::with_proc_view`].
2507    /// Read through [`Self::installed_proc_view`], which keys it on [`Self::tier`].
2508    pub(crate) proc_view: crate::proc_view::ProcView,
2509    /// The host folders `~/.murmur/config.yaml` lends this session ([`CapabilityPolicy::host_paths`]),
2510    /// in config order. On `KernelFull` and `KernelSealed` each gets a Landlock rule, `ReadFile |
2511    /// ReadDir` for `read` and the workdir's rights without `Execute` for `read-write`, taken on a
2512    /// descriptor opened at every spawn, fail-closed: a host path that can no longer be opened
2513    /// fails the spawn naming it. On `KernelSealed` each is also a required, verified bind at its
2514    /// own absolute path. Nothing is added on the tiers with no filesystem scope.
2515    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2516    pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
2517    /// On `KernelSealed` only: every execute root (see [`host_path_execute_roots`]) inside a
2518    /// `read-write` host path, bound read-only and required on top of it so the capsule cannot
2519    /// rewrite what it may run. Empty on every other tier, where such an entry is refused at
2520    /// staging instead.
2521    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2522    pub(crate) host_path_read_only_binds: Vec<PathBuf>,
2523    /// Whether the fd-hygiene step falls back to marking each descriptor close-on-exec one at a
2524    /// time where `close_range(CLOSE_RANGE_CLOEXEC)` is refused (a kernel before 5.11, or a
2525    /// container seccomp profile that does not know the call). `false` for a command, whose spawn
2526    /// fails there (see `linux_enforce::mark_inherited_fds_cloexec`); `true` for a guest worker
2527    /// ([`Self::for_guest_worker`]), without which no agent capsule could start on such a host.
2528    /// Either way every descriptor from 3 up is close-on-exec at `execve`.
2529    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2530    pub(crate) fd_hygiene_fallback: bool,
2531}
2532
2533/// What a `transport: process` harness is granted beyond every command: the host paths the
2534/// manifest allowed its driver's needs, and the files its `exec` runs — the resolved harness
2535/// binary and, for a `#!` script, the program its first line names (and `<name>` of
2536/// `#!/usr/bin/env <name>`). Passed to [`ShellEnforcement::with_harness_grants`], which derives the
2537/// harness's enforcement.
2538#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2539#[derive(Debug, Clone)]
2540pub(crate) struct HarnessEnforcementGrants {
2541    pub(crate) granted_paths: Vec<crate::host_paths::HostPath>,
2542    /// The binary first, then its `#!` launcher and interpreter
2543    /// ([`crate::harness_needs::HarnessBinaryInfo::exec_files`]).
2544    pub(crate) exec_files: Vec<PathBuf>,
2545}
2546
2547impl ShellEnforcement {
2548    /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
2549    ///
2550    /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
2551    /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
2552    /// caller creates and attaches with [`Self::with_host_bounding`].
2553    ///
2554    /// `decision` is the session's [`crate::containment::ContainmentDecision`], and its applied
2555    /// tier is the one installed: whether a sealed-capable host builds a composed root is decided
2556    /// there, from the declared floor, so the tier installed here is the one the session's trace
2557    /// names.
2558    ///
2559    /// `probe` is the reading `decision` was made from — for a session, the [`HostProbe`]
2560    /// `runtime::stage_session` took and carried on the `StagedSession` — and supplies the
2561    /// kernel's Landlock ABI.
2562    pub(crate) fn resolve(
2563        policy: &CapabilityPolicy,
2564        decision: &crate::containment::ContainmentDecision,
2565        probe: HostProbe,
2566    ) -> Result<Self, String> {
2567        let tier = decision.applied_tier();
2568        let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2569        let network_allow_rules =
2570            crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2571                .map_err(|error| error.to_string())?;
2572        let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2573        let bind_observation = (observes_binds_on(tier) && bind_notification_supported())
2574            .then(|| {
2575                crate::held_ports::HeldPorts::from_allowlist(
2576                    &policy.network_allow,
2577                    &egress_tcp_ports,
2578                )
2579            })
2580            .filter(|held| !held.is_empty());
2581        // Every `shell.allow` name, looked up on `PATH` and then in each pinned program's store
2582        // `bin/` folder, the order every command's `PATH` has.
2583        let exec_allow_paths = resolve_policy_exec_allowlist(policy);
2584        // The `DT_NEEDED`-closure files (individual files → non-listable), one grant per
2585        // author-declared `interpreter_runtime` directory (each with its own `list_dir`), and one
2586        // read-only grant per entry a recognised interpreter reported on its own import path at
2587        // staging. A directory neither the manifest names nor an allowlisted interpreter reports
2588        // never receives a rule, regardless of what it holds.
2589        let closure = resolve_landlock_grants(&exec_allow_paths);
2590        // Re-checked here, not only at staging: a plan step or a session check resolves its own
2591        // enforcement, and none may install a read-write host path over what it runs on
2592        // `KernelFull`. On `KernelSealed` the execute roots inside one come back to be bound
2593        // read-only on top of it.
2594        let host_path_read_only_binds = crate::host_paths::check_execute_roots(
2595            &policy.host_paths,
2596            &execute_roots_from(&closure, policy),
2597            host_path_mechanism(tier),
2598        )
2599        .map_err(|refusal| refusal.to_string())?;
2600        let mut landlock_grants = LandlockGrant::non_listable_files(closure);
2601        landlock_grants.extend(resolve_interpreter_runtime_grants(
2602            &policy.shell_interpreter_runtime,
2603        ));
2604        landlock_grants.extend(resolve_derived_interpreter_grants(
2605            &policy.shell_derived_interpreter_runtime,
2606        ));
2607        // Plus one read-only grant per host trust-store path staging found. Not tier-gated: the
2608        // network gate lives in the derivation, which leaves the field empty for a capsule whose
2609        // commands cannot reach the network.
2610        landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2611        // Plus one read-only grant per path a tool's declared program need resolved to. The
2612        // gate (an operator pin and the program in `shell.allow`) lives in the derivation.
2613        landlock_grants.extend(resolve_declared_need_grants(&policy.shell_declared_needs));
2614        // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
2615        // composed root and then denied by the Landlock ruleset installed inside it — see
2616        // `resolve_staged_runtime_landlock_grants`.
2617        landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2618        // Plus one listable, executable, never-writable grant per pinned program's store entry,
2619        // on every Landlock tier: the copy is reached through its `bin/` link, so the whole entry
2620        // must be readable, not only the closure of the file it resolves to.
2621        landlock_grants.extend(resolve_pinned_program_landlock_grants(policy));
2622        // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
2623        // directory — the composed root binds them but Landlock inside the root still denies
2624        // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
2625        // `scoped` capsule runs on) the same grant would expose real host directory shape. The
2626        // resolver applies that gate itself and returns nothing on every other tier.
2627        landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2628        // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
2629        // mounted-but-denied bug one directory over, and the reason TLS certificate verification
2630        // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
2631        // `KernelFull` these rules would apply to the *host's* /etc.
2632        landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2633        // And, on `KernelFull` only, `/etc/hosts` and `/etc/nsswitch.conf` read-only, so
2634        // `localhost` resolves; `sealed` has them from the line above.
2635        landlock_grants.extend(resolve_scoped_name_resolution_landlock_grants(tier));
2636        let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2637        let sealed_need_mirrors =
2638            resolve_sealed_need_mirrors(&policy.shell_declared_needs, &sealed_bind_dirs);
2639        let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2640        let pinned_program_dirs = resolve_pinned_program_dirs(policy);
2641        Ok(Self {
2642            tier,
2643            network_allow_ips,
2644            network_allow_rules,
2645            egress_tcp_ports,
2646            bind_observation,
2647            unix_sockets_allowed: policy.unix_sockets_allowed,
2648            workdir_exec: policy.workdir_exec_allowed,
2649            landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2650            landlock_grants,
2651            sealed_bind_dirs,
2652            sealed_need_mirrors,
2653            staged_runtime_dirs,
2654            pinned_program_dirs,
2655            resource_limits: policy.resources,
2656            declared_resources: policy.declared_resources,
2657            cgroup_scope: None,
2658            workdir_guard: None,
2659            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2660            runtime_dir: None,
2661            runtime_dir_owner: None,
2662            temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2663            proc_view: crate::proc_view::proc_view(tier, &probe.namespaces),
2664            host_paths: policy.host_paths.clone(),
2665            host_path_read_only_binds,
2666            fd_hygiene_fallback: false,
2667        })
2668    }
2669
2670    /// [`Self::resolve`] for a capsule declaring `declared`, decided from `probe` with the
2671    /// runtime directory outside the workdir: the seam tests resolve an enforcement through.
2672    #[cfg(test)]
2673    pub(crate) fn resolve_declared(
2674        policy: &CapabilityPolicy,
2675        declared: murmur_artifact::ContainmentClass,
2676        probe: HostProbe,
2677    ) -> Result<Self, String> {
2678        let decision = crate::containment::ContainmentDecision::decide(
2679            probe.tier(),
2680            declared,
2681            policy.workdir_exec_allowed,
2682            false,
2683        );
2684        Self::resolve(policy, &decision, probe)
2685    }
2686
2687    /// Attach the session's runtime directory, and with `owner` the directory itself, so a
2688    /// process exit that runs no destructor removes it ([`Self::remove_runtime_dir`]).
2689    pub(crate) fn with_runtime_dir(
2690        mut self,
2691        roots: crate::runtime_dir::RuntimeDirRoots,
2692        owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2693    ) -> Self {
2694        self.runtime_dir = Some(roots);
2695        self.runtime_dir_owner = owner;
2696        self
2697    }
2698
2699    /// The runtime directory's absolute path, the value of `$MURMUR_RUNTIME_DIR`.
2700    pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2701        self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2702    }
2703
2704    /// The session's network namespace, shared, when a command spawned under this enforcement
2705    /// runs inside it — every Linux kernel tier — for the tool bridge to serve the listener the
2706    /// namespace creator opened there. `None` on `Unconfined` and `EnvironmentOnly`, where the
2707    /// bridge binds the host loopback.
2708    pub(crate) fn bridge_netns(&self) -> Option<Arc<crate::network_namespace::SessionNetns>> {
2709        matches!(
2710            self.tier,
2711            EnforcementTier::KernelSealed
2712                | EnforcementTier::KernelFull
2713                | EnforcementTier::KernelSeccompOnly
2714        )
2715        .then(|| Arc::clone(&self.session_netns))
2716    }
2717
2718    /// The session's records directory, held. `None` for an enforcement held outside a session.
2719    pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2720        self.runtime_dir
2721            .as_ref()
2722            .and_then(|roots| roots.records.as_ref())
2723    }
2724
2725    /// The records directory's absolute path, the value of `$MURMUR_RECORDS_DIR`. `None` for an
2726    /// enforcement held outside a session.
2727    pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2728        self.records_root()
2729            .map(crate::workdir_fs::WorkdirRoot::path)
2730    }
2731
2732    /// The session's temporary folder's absolute path. `None` for an enforcement held outside a
2733    /// session.
2734    pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2735        self.runtime_dir
2736            .as_ref()
2737            .and_then(|roots| roots.temp.as_ref())
2738            .map(crate::workdir_fs::WorkdirRoot::path)
2739    }
2740
2741    /// The session's home folder, `home/` in its records directory, held. `None` for an
2742    /// enforcement held outside a session, which runs no command.
2743    pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2744        self.runtime_dir
2745            .as_ref()
2746            .and_then(|roots| roots.home.as_ref())
2747    }
2748
2749    /// The home folder's absolute path, the value of `$HOME` and the synthetic passwd's home
2750    /// directory on every tier. `None` for an enforcement held outside a session.
2751    pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2752        self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2753    }
2754
2755    /// Hand in the session's [`crate::temp_dir::temp_mapping`] answer, decided for this
2756    /// enforcement's tier.
2757    pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2758        self.temp_mapping = mapping;
2759        self
2760    }
2761
2762    /// What this enforcement installs for `/tmp` while it holds a folder: the composed root's bind
2763    /// on `KernelSealed`, a private mount namespace on `KernelFull` and `KernelSeccompOnly` when
2764    /// [`Self::temp_mapping`] says so, and nothing otherwise. Keyed on [`Self::tier`] as well, so
2765    /// no tier is ever told of a mechanism it does not install.
2766    pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2767        use crate::temp_dir::TempMapping;
2768        match (self.tier, self.temp_mapping) {
2769            (
2770                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2771                TempMapping::MountNamespace,
2772            ) => TempMapping::MountNamespace,
2773            (
2774                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2775                TempMapping::NotMapped(reason),
2776            ) => TempMapping::NotMapped(reason),
2777            (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2778        }
2779    }
2780
2781    /// Hand in the `/proc` view this enforcement's commands get, in place of the one
2782    /// [`Self::resolve`] decided from its probe.
2783    #[cfg(all(test, target_os = "linux"))]
2784    pub(crate) fn with_proc_view(mut self, view: crate::proc_view::ProcView) -> Self {
2785        self.proc_view = view;
2786        self
2787    }
2788
2789    /// What this enforcement installs for `/proc`: the private view on the kernel tiers when
2790    /// [`Self::proc_view`] says so, the host's otherwise. Keyed on [`Self::tier`], so no tier is
2791    /// told of a mechanism it does not install.
2792    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2793    pub(crate) fn installed_proc_view(&self) -> crate::proc_view::ProcView {
2794        match (self.tier, &self.proc_view) {
2795            (
2796                EnforcementTier::KernelFull
2797                | EnforcementTier::KernelSealed
2798                | EnforcementTier::KernelSeccompOnly,
2799                view,
2800            ) => view.clone(),
2801            (tier, _) => crate::proc_view::tier_proc_view(tier),
2802        }
2803    }
2804
2805    /// The value of `TMPDIR`, `TMP` and `TEMP` in every command this enforcement spawns: `/tmp`
2806    /// when a literal `/tmp` is the session's temporary folder, the folder's absolute path when
2807    /// not, and `None` when the enforcement holds no folder.
2808    pub(crate) fn tmpdir_value(&self) -> Option<String> {
2809        self.temp_dir_path()
2810            .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2811    }
2812
2813    /// Removes the session's runtime directory now, ahead of a process exit that runs no
2814    /// destructor. Does nothing on an enforcement that does not own one.
2815    pub(crate) fn remove_runtime_dir(&self) {
2816        if let Some(owner) = &self.runtime_dir_owner {
2817            owner.remove();
2818        }
2819    }
2820
2821    /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
2822    /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
2823    ///
2824    /// Separate from [`Self::resolve`] because both are *live* per-session resources with
2825    /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
2826    /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
2827    /// creation so the fail-closed launch refusal can happen with a typed error before this is
2828    /// ever reached.
2829    pub(crate) fn with_host_bounding(
2830        mut self,
2831        cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2832        workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2833    ) -> Self {
2834        self.cgroup_scope = cgroup_scope;
2835        self.workdir_guard = workdir_guard;
2836        self
2837    }
2838
2839    /// This session's enforcement plus exactly what a `transport: process` harness gets beyond
2840    /// every command: the granted host paths (a Landlock rule each, a bind on `sealed`) and the
2841    /// resolved harness binary as an executable grant — its file and `DT_NEEDED` closure, its
2842    /// folder bound on `sealed` — exactly like a `capabilities.shell.allow` binary, plus the
2843    /// interpreter a `#!` script names. A shell command keeps the session enforcement without any
2844    /// of this. On `Unconfined`/`EnvironmentOnly` nothing is added: there is no Landlock domain.
2845    ///
2846    /// Derived once per staging. Fails closed on a Landlock tier where a granted read-write host
2847    /// path overlaps the harness's own executable folder.
2848    pub(crate) fn with_harness_grants(
2849        &self,
2850        grants: &HarnessEnforcementGrants,
2851    ) -> Result<Self, String> {
2852        let mut enforcement = self.clone();
2853        if matches!(
2854            self.tier,
2855            EnforcementTier::Unconfined | EnforcementTier::EnvironmentOnly
2856        ) {
2857            return Ok(enforcement);
2858        }
2859        // The granted paths join the session's host paths: a Landlock rule each on both Landlock
2860        // tiers, never executable, and a bind at its own path on `sealed`.
2861        enforcement
2862            .host_paths
2863            .extend(grants.granted_paths.iter().cloned());
2864        // The harness binary (and its `#!` interpreter) granted exactly as a `shell.allow` binary:
2865        // its file and `DT_NEEDED` closure as non-listable executable Landlock rules.
2866        let exec_paths = &grants.exec_files;
2867        let closure = resolve_landlock_grants(exec_paths);
2868        // The sealed binds for the harness binary's and interpreter's own folders, so the
2869        // composed root carries them.
2870        for file in exec_paths {
2871            if let Some(dir) = file.parent() {
2872                push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
2873            }
2874        }
2875        // The execute roots the harness binary adds, for the host-path check: a granted read-write
2876        // path overlapping one is refused on `KernelFull` and bound read-only on `KernelSealed`.
2877        let harness_exec_roots: Vec<crate::host_paths::ExecuteRoot> = closure
2878            .iter()
2879            .filter_map(|file| {
2880                file.parent().map(|dir| crate::host_paths::ExecuteRoot {
2881                    dir: dir.to_path_buf(),
2882                    because: file.clone(),
2883                })
2884            })
2885            .collect();
2886        let extra_binds = crate::host_paths::check_execute_roots(
2887            &enforcement.host_paths,
2888            &harness_exec_roots,
2889            host_path_mechanism(self.tier),
2890        )
2891        .map_err(|refusal| refusal.to_string())?;
2892        enforcement.host_path_read_only_binds.extend(extra_binds);
2893        enforcement
2894            .landlock_grants
2895            .extend(LandlockGrant::non_listable_files(closure));
2896        Ok(enforcement)
2897    }
2898
2899    /// The `RLIMIT_NPROC` decision for one spawn, made in the parent before its `pre_exec`
2900    /// closure is installed.
2901    ///
2902    /// [`crate::resources::NprocBound::CgroupPidsMax`] when the spawn will join a scope that
2903    /// places it, because both closures that receive this run the join fatally before `execve`.
2904    /// Otherwise [`crate::resources::NprocBound::Headroom`], with a baseline measured by
2905    /// `crate::resources::uid_task_count` on this call — a `/proc` walk on Linux — so call it
2906    /// once per spawn and never from inside `pre_exec`.
2907    pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2908        if self
2909            .cgroup_scope
2910            .as_ref()
2911            .is_some_and(|scope| scope.places_subprocesses())
2912        {
2913            crate::resources::NprocBound::CgroupPidsMax
2914        } else {
2915            crate::resources::NprocBound::Headroom {
2916                baseline: crate::resources::uid_task_count().unwrap_or(0),
2917            }
2918        }
2919    }
2920
2921    /// The latched workdir-size breach, if the guard has seen one.
2922    pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2923        self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2924    }
2925
2926    /// Refuse to spawn once the workdir ceiling has been crossed.
2927    ///
2928    /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
2929    /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
2930    /// the periodic check notices the breach, and the very next subprocess never starts.
2931    pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2932        match self.workdir_breach() {
2933            Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2934            None => Ok(()),
2935        }
2936    }
2937
2938    /// The permanent macOS/non-Linux value, and also what tests should construct directly to
2939    /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
2940    /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
2941    /// test code calls this directly today — production always goes through `resolve()`,
2942    /// which independently arrives at the same tier value on non-Linux hosts.)
2943    #[cfg_attr(not(test), allow(dead_code))]
2944    pub(crate) fn environment_only() -> Self {
2945        Self {
2946            tier: EnforcementTier::EnvironmentOnly,
2947            network_allow_ips: Vec::new(),
2948            network_allow_rules: Vec::new(),
2949            egress_tcp_ports: Vec::new(),
2950            bind_observation: None,
2951            // Denied, matching every other grant this constructor zeroes out. Inert on this tier
2952            // (no seccomp filter is installed at all), but it must not read as "allowed".
2953            unix_sockets_allowed: false,
2954            // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
2955            // Landlock domain exists to withhold anything from), but it must not read as "allowed".
2956            workdir_exec: false,
2957            // No Landlock domain on this tier. A test that spreads this over a Landlock tier
2958            // builds at ABI v1, the narrowest set.
2959            landlock_abi: None,
2960            landlock_grants: Vec::new(),
2961            sealed_bind_dirs: Vec::new(),
2962            sealed_need_mirrors: Vec::new(),
2963            staged_runtime_dirs: Vec::new(),
2964            pinned_program_dirs: Vec::new(),
2965            // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
2966            // zeroing them out here would misrepresent what a real macOS host does.
2967            resource_limits: crate::resources::HostResourceLimits::default(),
2968            declared_resources: murmur_artifact::ResourceCapabilities::default(),
2969            cgroup_scope: None,
2970            workdir_guard: None,
2971            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2972            runtime_dir: None,
2973            runtime_dir_owner: None,
2974            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2975            proc_view: crate::proc_view::tier_proc_view(EnforcementTier::EnvironmentOnly),
2976            host_paths: Vec::new(),
2977            host_path_read_only_binds: Vec::new(),
2978            fd_hygiene_fallback: false,
2979        }
2980    }
2981
2982    /// This enforcement holding a session's runtime directory roots, home folder included, in a
2983    /// fresh directory kept past the test: a clone may outlive the value. For a test whose
2984    /// commands need only to run.
2985    #[cfg(test)]
2986    pub(crate) fn with_test_session_dirs(self) -> Self {
2987        let base = tempfile::tempdir().unwrap().keep();
2988        self.with_runtime_dir(test_runtime_dir(&base), None)
2989    }
2990
2991    /// Run this enforcement's commands in `session_netns` rather than in a namespace of its own.
2992    ///
2993    /// For a plan submitted inside a session: its shell steps are that session's commands, so
2994    /// they share its loopback and its egress proxy.
2995    pub(crate) fn with_session_netns(
2996        mut self,
2997        session_netns: Arc<crate::network_namespace::SessionNetns>,
2998    ) -> Self {
2999        self.session_netns = session_netns;
3000        self
3001    }
3002
3003    /// The held-port map this enforcement's commands are observed against, or `None` when they
3004    /// get today's filter with `bind` allowed: always on a tier other than `KernelFull` and
3005    /// `KernelSealed`, whatever [`Self::bind_observation`] holds.
3006    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3007    pub(crate) fn observed_held_ports(&self) -> Option<&crate::held_ports::HeldPorts> {
3008        self.bind_observation
3009            .as_ref()
3010            .filter(|_| observes_binds_on(self.tier))
3011    }
3012
3013    /// One [`crate::held_ports::held_port_line`] per held port a process of this session tried to
3014    /// bind since the last call, ascending, draining the session's pending records. Shared by
3015    /// every enforcement of the session through its [`crate::network_namespace::SessionNetns`].
3016    pub(crate) fn take_held_port_lines(&self) -> Vec<String> {
3017        self.session_netns.take_held_port_lines()
3018    }
3019
3020    /// End the session: kill every process it started, then release its network namespace.
3021    ///
3022    /// Killing goes through the cgroup scope, which every spawn joins before `execve` and no fork
3023    /// escapes, so a server backgrounded with `&`, a `setsid` double fork and a demoted command
3024    /// still running are all in it. The namespace is released only once the scope is empty (or
3025    /// [`SESSION_END_KILL_WAIT`] has passed), so its proxy never stops under a process still
3026    /// using it. Idempotent; every spawn on this enforcement afterwards is refused.
3027    pub(crate) fn end_session(&self) {
3028        self.kill_session_processes();
3029        self.session_netns.end();
3030    }
3031
3032    /// Kill every process in this enforcement's cgroup scope and wait, bounded by
3033    /// [`SESSION_END_KILL_WAIT`], for the scope to empty. Does nothing without a scope.
3034    ///
3035    /// The half of [`Self::end_session`] a plan runs at its own end: a plan's scope is its own,
3036    /// and the namespace may be its session's.
3037    pub(crate) fn kill_session_processes(&self) {
3038        let Some(scope) = self.cgroup_scope.as_ref() else {
3039            return;
3040        };
3041        if let Err(error) = scope.kill_all() {
3042            crate::runtime_err!(
3043                "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
3044                 {error}",
3045                scope.path().display()
3046            );
3047        }
3048        if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
3049            crate::runtime_err!(
3050                "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
3051                 they were killed",
3052                scope.path().display(),
3053                SESSION_END_KILL_WAIT.as_millis()
3054            );
3055        }
3056    }
3057}
3058
3059/// Whether `tier` routes `bind(2)` to the session's observer when the allowlist holds a port.
3060///
3061/// The Landlock tiers only: Landlock needs Linux 5.13, which has seccomp user notification (5.0),
3062/// its continue flag (5.5) and `POLLHUP` on a listener whose tasks have all exited (5.8), so no
3063/// kernel version is read. `KernelSeccompOnly` reaches kernels older than that.
3064fn observes_binds_on(tier: EnforcementTier) -> bool {
3065    matches!(
3066        tier,
3067        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3068    )
3069}
3070
3071/// Whether this runtime's libseccomp can build a filter that notifies and hand back its listener:
3072/// API level 6 of libseccomp 2.5.0 or later, the level the `libseccomp` crate's notification calls
3073/// require.
3074#[cfg(target_os = "linux")]
3075fn bind_notification_supported() -> bool {
3076    libseccomp::check_api(6, libseccomp::ScmpVersion::from((2, 5, 0))).unwrap_or(false)
3077}
3078
3079#[cfg(not(target_os = "linux"))]
3080fn bind_notification_supported() -> bool {
3081    false
3082}
3083
3084/// How long [`ShellEnforcement::end_session`] waits for a killed scope's `cgroup.procs` to empty.
3085pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
3086
3087/// Calls [`ShellEnforcement::end_session`] when dropped.
3088///
3089/// Held by a launch for its whole length, so a session that ends by an error return still kills
3090/// what it started. Every path that ends a session normally calls `end_session` itself first, at
3091/// the point its teardown needs it; the call this makes then finds nothing left to do.
3092pub(crate) struct EndSessionOnDrop(ShellEnforcement);
3093
3094impl EndSessionOnDrop {
3095    pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
3096        Self(enforcement)
3097    }
3098}
3099
3100impl Drop for EndSessionOnDrop {
3101    fn drop(&mut self) {
3102        self.0.end_session();
3103    }
3104}
3105
3106/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
3107/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
3108/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
3109/// one or two concise sentences.
3110///
3111/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
3112/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
3113/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
3114/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
3115/// dynamically link, and the only writable paths outside the workdir are the session's temporary
3116/// and home folders, any `read-write` host path ([`crate::host_paths`]) and `/dev/null`, one of the
3117/// three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp
3118/// additionally refuses the `socket(2)` domains in `denied_socket_domains` — closing the
3119/// `/var/run/docker.sock` path, which no Landlock ABI mediates for pathname sockets. The forked
3120/// shell child also drops every Linux capability before `execve` (see
3121/// `linux_enforce::drop_all_capabilities`).
3122/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
3123/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
3124const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
3125resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
3126derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
3127shared libraries — nothing writable), read-only access to the directories an allowlisted \
3128CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
3129trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
3130~/.murmur/config.yaml at its own access, never executable, read-only access to /etc/hosts and \
3131/etc/nsswitch.conf so localhost resolves, read and list access to the session's private /proc \
3132(commands run in the session's own PID namespace and its procfs lists only the session's processes \
3133and the kernel's world-readable files — no host process; where this host cannot mount one, /proc \
3134is the host's and every open beneath it is denied), and no other directory granted \
3135wholesale, plus a fixed device set \
3136every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
3137folders and any read-write host_paths entry, the only writable paths outside the workdir), \
3138/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
3139raw block devices stay denied. The workdir's own grant also \
3140withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
3141capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
3142capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
3143defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
3144profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
3145and the forked shell child \
3146drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
3147denied, not absent: stat, access and readlink on an ungranted path still succeed.";
3148
3149/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
3150/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
3151/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
3152const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
3153host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
3154namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
3155its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
3156writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
3157/dev tmpfs carrying the OCI default device set), with Landlock \
3158and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
3159rather than denied. Commands run in the session's own PID namespace, and /proc is a bind of the \
3160session's private procfs: it lists only the session's own processes and the kernel's \
3161world-readable files, readable and listable, and no host process appears in it or can be \
3162signalled. Where this host cannot mount a private procfs (mur doctor's private proc line), /proc \
3163is a bind of the host's instead, Landlock refuses every open beneath it, and stat(2) on \
3164/proc/<pid> still shows that a host process exists, its owner and its start time.";
3165
3166const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
3167lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
3168kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
3169verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
3170read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
3171mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
3172exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
3173capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
3174rule needs no Landlock and so applies identically on this tier, but it has not been verified \
3175either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
3176profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
3177tier too. The forked shell child still drops every Linux capability and \
3178sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
3179verified either. Treat shell subprocess isolation as experimental on this host.";
3180
3181const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
3182platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
3183enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
3184permanent on this platform.";
3185
3186/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
3187///
3188///   - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
3189///     is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
3190///   - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
3191///     (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
3192///   - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
3193///     sandboxing primitive at all on this platform, and this is permanent. Also still calls
3194///     `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
3195///     nothing is kernel-enforced.
3196///
3197/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
3198/// assert what each tier warns without capturing stderr.
3199pub(crate) fn tier_warning(
3200    tier: EnforcementTier,
3201    shell_allow_is_empty: bool,
3202) -> Option<(&'static str, &'static str)> {
3203    if shell_allow_is_empty {
3204        return None;
3205    }
3206    match tier {
3207        EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
3208        EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
3209        EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
3210        EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
3211        // The launch prints `W-SEC-036` instead, which names what the capsule can reach.
3212        EnforcementTier::Unconfined => None,
3213    }
3214}
3215
3216const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn subprocesses, but \
3217this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
3218tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
3219capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
3220does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
3221This is permanent on this platform, not a placeholder for a future slice.";
3222
3223/// Pure decision for the aggregate-bounding warning, split out of
3224/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
3225/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
3226///
3227/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
3228/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
3229/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
3230/// there is no running session left to warn about.
3231pub(crate) fn aggregate_bounding_warning(
3232    is_linux: bool,
3233    requires_bounding: bool,
3234    has_scope: bool,
3235) -> Option<(&'static str, &'static str)> {
3236    if is_linux || !requires_bounding || has_scope {
3237        return None;
3238    }
3239    Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
3240}
3241
3242/// Fires at every launch, not just once.
3243pub(crate) fn warn_for_missing_aggregate_bounding(
3244    workdir: &crate::workdir_fs::WorkdirRoot,
3245    requires_bounding: bool,
3246    has_scope: bool,
3247) {
3248    let is_linux = cfg!(target_os = "linux");
3249    if let Some((code, message)) =
3250        aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
3251    {
3252        let link = security_warning_link(code);
3253        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3254        crate::agent::append_bootstrap_log(
3255            workdir,
3256            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3257        );
3258    }
3259}
3260
3261/// Fires at every launch, not just once.
3262pub(crate) fn warn_for_enforcement_tier(
3263    tier: EnforcementTier,
3264    workdir: &crate::workdir_fs::WorkdirRoot,
3265    policy: &CapabilityPolicy,
3266) {
3267    if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
3268        let link = security_warning_link(code);
3269        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3270        crate::agent::append_bootstrap_log(
3271            workdir,
3272            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3273        );
3274    }
3275    if tier == EnforcementTier::EnvironmentOnly {
3276        crate::runtime::warn_if_bash_network_bypass(workdir, policy);
3277    }
3278}
3279
3280/// What `prepare_enforcement` hands back alongside the configured `Command`: on a kernel tier, the
3281/// read end of the child's diagnostic pipe.
3282///
3283/// The session's egress proxy is not here. It belongs to the session's
3284/// [`crate::network_namespace::SessionNetns`] and outlives every command, so a process a command
3285/// backgrounds keeps its allowlisted egress after the command returns.
3286#[derive(Debug)]
3287pub(crate) enum SupervisorHandle {
3288    Noop,
3289    #[cfg(target_os = "linux")]
3290    Linux {
3291        /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
3292        /// child's `pre_exec` closure. On a successful `execve` the write end closes
3293        /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
3294        /// setup failure the child writes the real error message here before returning `Err`,
3295        /// letting `execute_shell` fold that legible detail into the error it returns instead of
3296        /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
3297        diag_read: std::os::fd::OwnedFd,
3298        /// The parent's end of the `SOCK_SEQPACKET` socketpair the child sends its seccomp
3299        /// listener over, when the session's commands are observed for a bind on a held port
3300        /// ([`ShellEnforcement::observed_held_ports`]); `None` otherwise. Read once, by
3301        /// [`SupervisorHandle::hand_off_bind_listener`] after a successful spawn.
3302        bind_listener: Option<std::os::fd::OwnedFd>,
3303    },
3304}
3305
3306impl SupervisorHandle {
3307    /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
3308    /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
3309    /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
3310    /// there is zero cost there.
3311    ///
3312    /// The caller must have already released every *parent-side* copy of the pipe's write end
3313    /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
3314    /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
3315    /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
3316    /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
3317    /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
3318    /// failing).
3319    pub(crate) fn read_diagnostic(&self) -> Option<String> {
3320        match self {
3321            SupervisorHandle::Noop => None,
3322            #[cfg(target_os = "linux")]
3323            SupervisorHandle::Linux { diag_read, .. } => {
3324                use std::os::fd::AsRawFd;
3325                linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3326            }
3327        }
3328    }
3329
3330    /// After a successful `spawn()`: receives the seccomp listener the child sent before its
3331    /// `execve` and hands it to `enforcement`'s session observer. Does nothing for a spawn that
3332    /// is not observed.
3333    ///
3334    /// `spawn()` returns only once the command has called `execve`, and the child sends the
3335    /// listener before that, so the receive never waits. A listener that cannot be received is
3336    /// reported as a warning: a bind in that command then fails with `ENOSYS`.
3337    pub(crate) fn hand_off_bind_listener(&self, enforcement: &ShellEnforcement) {
3338        #[cfg(target_os = "linux")]
3339        if let SupervisorHandle::Linux {
3340            bind_listener: Some(socket),
3341            ..
3342        } = self
3343        {
3344            match linux_enforce::receive_bind_listener(socket) {
3345                Ok(listener) => enforcement.session_netns.observe_bind_listener(listener),
3346                Err(error) => crate::runtime_err!(
3347                    "[capsule-runtime] warning: could not receive a command's bind listener: \
3348                     {error}; a bind in that command fails with ENOSYS"
3349                ),
3350            }
3351        }
3352        #[cfg(not(target_os = "linux"))]
3353        let _ = (self, enforcement);
3354    }
3355}
3356
3357// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
3358// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
3359// test builds; production `prepare_enforcement` has no bypass or injection point.
3360#[cfg(test)]
3361thread_local! {
3362    pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3363        const { std::cell::Cell::new(false) };
3364}
3365
3366#[cfg(test)]
3367fn forced_prepare_failure() -> Result<(), String> {
3368    if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3369        return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3370    }
3371    Ok(())
3372}
3373
3374// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
3375// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
3376// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
3377// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
3378// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
3379// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
3380// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
3381// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
3382#[cfg(all(test, target_os = "linux"))]
3383thread_local! {
3384    pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3385        const { std::cell::Cell::new(false) };
3386    pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3387        const { std::cell::Cell::new(false) };
3388    /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
3389    /// only fail on a host that can attempt it at all, and the thing under test — that a
3390    /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
3391    /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
3392    /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
3393    pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3394        const { std::cell::Cell::new(false) };
3395    /// Fails the `/tmp` mapping of a `MountNamespace` spawn at its first step, before any real
3396    /// namespace syscall, so a failed mapping's path is testable on any Linux host.
3397    pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3398        const { std::cell::Cell::new(false) };
3399    /// Fails the private `/proc` mount of a spawn whose view is private, at the mount itself and
3400    /// with `EPERM`, so the fail-closed path is testable on any host that mounts one.
3401    pub(crate) static FORCE_PRIVATE_PROC_FAILURE: std::cell::Cell<bool> =
3402        const { std::cell::Cell::new(false) };
3403}
3404
3405/// RAII arm for [`FORCE_PRIVATE_PROC_FAILURE`].
3406#[cfg(all(test, target_os = "linux"))]
3407pub(crate) struct ForcePrivateProcFailureGuard;
3408
3409#[cfg(all(test, target_os = "linux"))]
3410impl ForcePrivateProcFailureGuard {
3411    pub(crate) fn new() -> Self {
3412        FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(true));
3413        Self
3414    }
3415}
3416
3417#[cfg(all(test, target_os = "linux"))]
3418impl Drop for ForcePrivateProcFailureGuard {
3419    fn drop(&mut self) {
3420        FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(false));
3421    }
3422}
3423
3424/// RAII arm for [`FORCE_TMP_MAP_FAILURE`].
3425#[cfg(all(test, target_os = "linux"))]
3426pub(crate) struct ForceTmpMapFailureGuard;
3427
3428#[cfg(all(test, target_os = "linux"))]
3429impl ForceTmpMapFailureGuard {
3430    pub(crate) fn new() -> Self {
3431        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3432        Self
3433    }
3434}
3435
3436#[cfg(all(test, target_os = "linux"))]
3437impl Drop for ForceTmpMapFailureGuard {
3438    fn drop(&mut self) {
3439        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3440    }
3441}
3442
3443/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
3444/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
3445/// and `runtime`'s (which checks what the dispatch layer does with it).
3446#[cfg(all(test, target_os = "linux"))]
3447pub(crate) struct ForceSealedRootFailureGuard;
3448
3449#[cfg(all(test, target_os = "linux"))]
3450impl ForceSealedRootFailureGuard {
3451    pub(crate) fn new() -> Self {
3452        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3453        Self
3454    }
3455}
3456
3457#[cfg(all(test, target_os = "linux"))]
3458impl Drop for ForceSealedRootFailureGuard {
3459    fn drop(&mut self) {
3460        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3461    }
3462}
3463
3464/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
3465/// half of the sealed mechanism still runs for real against it (the composed root is planned and
3466/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
3467/// which is what makes these tests run identically on a sealed-capable host and on one that could
3468/// never reach `KernelSealed` at all.
3469#[cfg(all(test, target_os = "linux"))]
3470pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3471    // Kept past the test: the enforcement may be cloned into a thread that outlives the value.
3472    let base = tempfile::tempdir().unwrap().keep();
3473    ShellEnforcement {
3474        tier: EnforcementTier::KernelSealed,
3475        ..ShellEnforcement::environment_only().with_test_session_dirs()
3476    }
3477    .with_runtime_dir(test_runtime_dir(&base), None)
3478}
3479
3480/// `runtime/`, `sealed-etc/`, `harness/` and a records directory holding a temporary folder and
3481/// a home folder, created under `base`, held as a session's runtime directory roots.
3482#[cfg(test)]
3483pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3484    let open = |sub: &str| {
3485        let path = base.join(sub);
3486        std::fs::create_dir_all(&path).unwrap();
3487        crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3488    };
3489    crate::runtime_dir::RuntimeDirRoots::new(
3490        open(crate::runtime_dir::RUNTIME_SUBDIR),
3491        open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3492        open(crate::runtime_dir::HARNESS_SUBDIR),
3493    )
3494    .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3495    .with_temp(open(&format!(
3496        "{}/{}",
3497        crate::records_dir::RECORDS_DIR_NAME,
3498        crate::temp_dir::SESSION_TEMP_DIR_NAME
3499    )))
3500    .with_home(open(&format!(
3501        "{}/{}",
3502        crate::records_dir::RECORDS_DIR_NAME,
3503        crate::records_dir::HOME_SUBDIR
3504    )))
3505}
3506
3507/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
3508/// exists — to `command`, so they apply to the spawned process and everything it forks.
3509///
3510/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
3511/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
3512/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. So this is
3513/// the whole of `prepare_enforcement` on `EnvironmentOnly` and off Linux, through which every
3514/// shell command, plan shell step and `murmur:process` program is spawned.
3515///
3516/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
3517/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
3518/// allocation, no locking, no path resolution.
3519#[cfg(unix)]
3520pub(crate) fn attach_process_limits(
3521    command: &mut std::process::Command,
3522    enforcement: &ShellEnforcement,
3523) {
3524    use std::os::unix::process::CommandExt;
3525
3526    if enforcement.tier == EnforcementTier::Unconfined {
3527        attach_unconfined_limits(command, enforcement);
3528        return;
3529    }
3530
3531    let limits = enforcement.resource_limits;
3532    let nproc = enforcement.nproc_bound();
3533    let cgroup_scope = enforcement.cgroup_scope.clone();
3534
3535    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3536    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
3537    // scope exists, a single `write` to a descriptor opened by the parent before the fork —
3538    // no allocation, no locks, no path lookups.
3539    #[allow(unsafe_code)]
3540    unsafe {
3541        command.pre_exec(move || {
3542            crate::resources::apply_hard_rlimits(&limits, nproc)?;
3543            if let Some(scope) = cgroup_scope.as_ref() {
3544                scope.join_current_process()?;
3545            }
3546            Ok(())
3547        });
3548    }
3549}
3550
3551/// [`attach_process_limits`] on [`EnforcementTier::Unconfined`]: only the rlimits
3552/// [`ShellEnforcement::declared_resources`] declares, the cgroup join when a scope exists, and on
3553/// Linux the fd-hygiene step. Nothing else: no `RLIMIT_CORE`, no default ceiling, so an
3554/// undeclared limit is the one `mur run` itself has.
3555///
3556/// Same `pre_exec` restrictions as [`attach_process_limits`].
3557#[cfg(unix)]
3558fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
3559    use std::os::unix::process::CommandExt;
3560
3561    let declared = enforcement.declared_resources;
3562    let nproc = enforcement.nproc_bound();
3563    let cgroup_scope = enforcement.cgroup_scope.clone();
3564
3565    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3566    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs, at most one `write` to
3567    // a descriptor opened by the parent before the fork, and on Linux one `close_range` (or a
3568    // bounded `fcntl` loop) — no allocation, no locks, no path lookups.
3569    #[allow(unsafe_code)]
3570    unsafe {
3571        command.pre_exec(move || {
3572            crate::resources::apply_declared_rlimits(&declared, nproc)?;
3573            if let Some(scope) = cgroup_scope.as_ref() {
3574                scope.join_current_process()?;
3575            }
3576            #[cfg(target_os = "linux")]
3577            linux_enforce::mark_inherited_fds_cloexec()?;
3578            Ok(())
3579        });
3580    }
3581}
3582
3583/// A guest worker's filesystem reach beyond the work folder, derived by the supervisor from the
3584/// grants of the guests the worker may hold and nothing else: the union of their per-call
3585/// preopens, because the kernel sandbox is installed once, at the worker's spawn.
3586///
3587/// The work folder itself is the `accessible_workdir` handed to [`prepare_enforcement`] beside
3588/// the worker's enforcement, read-write and never executable.
3589#[derive(Debug, Clone, Default, PartialEq, Eq)]
3590pub(crate) struct WorkerGrantClosure {
3591    /// Folders outside the work folder the guests write: granted `capabilities.state` stores and
3592    /// a hook's records `hooks/` folder. Each is a read-write rule without `Execute` on the
3593    /// Landlock tiers and a required bind at its own path on `KernelSealed`.
3594    pub(crate) read_write: Vec<PathBuf>,
3595    /// Whether a guest of the worker preopens the session runtime directory (read-only).
3596    pub(crate) runtime_dir: bool,
3597    /// Whether a guest of the worker preopens the `host_paths` roots of `~/.murmur/config.yaml`
3598    /// (a tool with no filesystem scope), each at its configured access.
3599    pub(crate) host_paths: bool,
3600}
3601
3602impl ShellEnforcement {
3603    /// The enforcement a guest worker process is spawned under: this session enforcement's tier,
3604    /// namespaces, network allowlist and seccomp posture, with the worker's own grant closure in
3605    /// place of every rule a shell command gets.
3606    ///
3607    /// - Landlock: `closure`, the work folder (passed to [`prepare_enforcement`] separately) with
3608    ///   [`Self::workdir_exec`] off, and `worker_image`'s own ELF closure, the one grant carrying
3609    ///   `Execute`, which the spawn's own `execve` of the worker needs. No guest grant carries
3610    ///   `Execute`: a guest execs nothing, and `murmur:process` programs are spawned by the
3611    ///   supervisor. No `shell.allow` closure, interpreter, staged runtime, pinned program or
3612    ///   declared need.
3613    /// - The records directory is dropped from the runtime roots, so neither a rule nor a bind
3614    ///   names it. On `KernelSealed` the runtime directory, temporary and home folders stay,
3615    ///   because the composed root is built from them; on the other tiers the runtime directory
3616    ///   is kept only when `closure.runtime_dir` asks for it, without the temporary and home
3617    ///   folders, so no private `/tmp` is mapped.
3618    /// - rlimits: `RLIMIT_AS`, the `RLIMIT_NPROC` headroom and `RLIMIT_CPU` are left at the
3619    ///   inherited values. Wasmtime reserves several GiB of address space for each linear memory,
3620    ///   a worker's threads count against the uid's task total, and a worker lives the whole
3621    ///   session; a guest call is bounded by its fuel, epoch deadline and wall bound instead, and
3622    ///   the worker by the session's cgroup scope. `RLIMIT_NOFILE`, `RLIMIT_FSIZE` and
3623    ///   `RLIMIT_CORE` apply as to a command.
3624    /// - No cgroup join of its own: staging forks the worker after `cgroup::await_delegated_base`
3625    ///   has put `mur` in the cgroup it stays in, so the worker, its relay and the session
3626    ///   namespace's holders inherit that cgroup. No held-port observation, and no ownership of
3627    ///   the runtime directory.
3628    pub(crate) fn for_guest_worker(
3629        &self,
3630        closure: &WorkerGrantClosure,
3631        worker_image: &Path,
3632    ) -> ShellEnforcement {
3633        let mut worker = self.clone();
3634        worker.landlock_grants = LandlockGrant::non_listable_files(resolve_landlock_grants(&[
3635            worker_image.to_path_buf(),
3636        ]));
3637        worker.sealed_bind_dirs = Vec::new();
3638        worker.sealed_need_mirrors = Vec::new();
3639        worker.staged_runtime_dirs = Vec::new();
3640        worker.pinned_program_dirs = Vec::new();
3641        worker.host_path_read_only_binds = Vec::new();
3642        worker.fd_hygiene_fallback = true;
3643        worker.workdir_exec = false;
3644        worker.bind_observation = None;
3645        worker.cgroup_scope = None;
3646        worker.workdir_guard = None;
3647        worker.runtime_dir_owner = None;
3648        worker.resource_limits.memory_bytes = u64::MAX;
3649        worker.resource_limits.max_processes = u64::MAX;
3650        worker.resource_limits.cpu_seconds = None;
3651        worker.declared_resources.memory_bytes = None;
3652        worker.declared_resources.max_processes = None;
3653        worker.declared_resources.cpu_seconds = None;
3654        worker.runtime_dir = self.runtime_dir.clone().and_then(|mut roots| {
3655            roots.records = None;
3656            if self.tier == EnforcementTier::KernelSealed {
3657                Some(roots)
3658            } else if closure.runtime_dir {
3659                roots.temp = None;
3660                roots.home = None;
3661                Some(roots)
3662            } else {
3663                None
3664            }
3665        });
3666        let mut host_paths = if closure.host_paths {
3667            self.host_paths.clone()
3668        } else {
3669            Vec::new()
3670        };
3671        host_paths.extend(
3672            closure
3673                .read_write
3674                .iter()
3675                .map(|path| crate::host_paths::HostPath {
3676                    declared: path.display().to_string(),
3677                    path: path.clone(),
3678                    access: crate::host_paths::HostPathAccess::ReadWrite,
3679                    credential_locations: Vec::new(),
3680                }),
3681        );
3682        worker.host_paths = host_paths;
3683        worker
3684    }
3685}
3686
3687/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
3688/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
3689///
3690/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles a
3691/// spawn installs *before* `pre_exec` runs exist so the exec'd program inherits them across its
3692/// own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
3693/// stdout/stderr. Marking 0/1/2
3694/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
3695/// invocation, and there is nothing meaningful to reopen them onto: the pipes themselves are the
3696/// intended destination. The gap this constant closes is everything *above*
3697/// stdio, where inheritance was never intended and was only ever prevented by the empirical
3698/// accident of nothing else happening to be open at spawn time.
3699///
3700/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
3701/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
3702/// module carry.
3703#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3704pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3705
3706/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
3707/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
3708/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
3709/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
3710/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
3711/// no kernel sandbox at all.
3712#[cfg(not(target_os = "linux"))]
3713pub(crate) fn prepare_enforcement(
3714    command: &mut std::process::Command,
3715    enforcement: &ShellEnforcement,
3716    _accessible_workdir: &Path,
3717) -> Result<SupervisorHandle, String> {
3718    #[cfg(test)]
3719    forced_prepare_failure()?;
3720
3721    debug_assert!(
3722        matches!(
3723            enforcement.tier,
3724            EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3725        ),
3726        "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
3727    );
3728    attach_process_limits(command, enforcement);
3729    Ok(SupervisorHandle::Noop)
3730}
3731
3732/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
3733/// submodule for the mechanics: the session's network namespace is established in the parent,
3734/// and the `pre_exec` closure sets `RLIMIT_NPROC` or joins the cgroup scope, then joins the
3735/// namespace, then maps `/tmp` or installs the composed root, then seccomp and Landlock, and
3736/// applies the per-process rlimits last.
3737#[cfg(target_os = "linux")]
3738#[allow(unsafe_code)]
3739pub(crate) fn prepare_enforcement(
3740    command: &mut std::process::Command,
3741    enforcement: &ShellEnforcement,
3742    accessible_workdir: &Path,
3743) -> Result<SupervisorHandle, String> {
3744    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3745    use std::os::unix::process::CommandExt;
3746
3747    #[cfg(test)]
3748    forced_prepare_failure()?;
3749
3750    if matches!(
3751        enforcement.tier,
3752        EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3753    ) {
3754        // No kernel sandbox to install. `EnvironmentOnly` keeps the POSIX rlimit ceilings — it is
3755        // only reachable on Linux from tests, and even there it must not read as "unbounded" —
3756        // and `Unconfined` keeps the declared limits and the fd-hygiene step.
3757        attach_process_limits(command, enforcement);
3758        return Ok(SupervisorHandle::Noop);
3759    }
3760
3761    // The session's temporary folder, reopened from its root's held descriptor: its Landlock
3762    // rule's fd on both Landlock tiers, and the identity the sealed bind and the mount-namespace
3763    // bind both verify, so a link swapped onto its path redirects neither.
3764    let temp_dir_fd = enforcement
3765        .runtime_dir
3766        .as_ref()
3767        .and_then(|roots| roots.temp.as_ref())
3768        .map(|temp| temp.path_fd().map(Arc::new))
3769        .transpose()
3770        .map_err(|error| {
3771            format!("sandbox: failed to open the session's temporary folder: {error}")
3772        })?;
3773    // The session's home folder, on exactly the temporary folder's terms: its Landlock rule's fd
3774    // on both Landlock tiers and the identity its sealed bind verifies.
3775    let home_dir_fd = enforcement
3776        .home_root()
3777        .map(|home| home.path_fd().map(Arc::new))
3778        .transpose()
3779        .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3780    // Every host path: opened on both Landlock tiers and on no other, and fail-closed. A configured
3781    // host path that can no longer be opened fails this spawn naming it; it is never dropped.
3782    let host_path_fds: Vec<HostPathFd> = if matches!(
3783        enforcement.tier,
3784        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3785    ) {
3786        enforcement
3787            .host_paths
3788            .iter()
3789            .map(|host_path| {
3790                linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
3791                    fd: Arc::new(fd),
3792                    path: host_path.path.clone(),
3793                    read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
3794                })
3795            })
3796            .collect::<Result<_, _>>()?
3797    } else {
3798        Vec::new()
3799    };
3800
3801    // The descriptors a rule and a bind share on `KernelSealed`: the temporary folder and the
3802    // runtime and records directories, each taken once, with no link followed, from a held
3803    // descriptor. The Landlock rule is taken on the descriptor, and the bind is refused unless
3804    // the directory the child reaches is the one it names, so a link swapped in afterwards
3805    // changes neither.
3806    let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3807        Some(SealedSourceFds::open(
3808            enforcement,
3809            temp_dir_fd.as_ref(),
3810            home_dir_fd.as_ref(),
3811        )?)
3812    } else {
3813        None
3814    };
3815    // On `KernelFull` the runtime directory needs the Landlock rule alone.
3816    let runtime_dir_fd = match &sealed_sources {
3817        Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3818        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3819            .runtime_dir
3820            .as_ref()
3821            .map(|roots| roots.runtime.path_fd().map(Arc::new))
3822            .transpose()
3823            .map_err(|error| {
3824                format!(
3825                    "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3826                )
3827            })?,
3828        None => None,
3829    };
3830    // The records directory takes the runtime directory's rule on both Landlock tiers, from the
3831    // same descriptor its sealed bind verifies.
3832    let records_dir_fd = match &sealed_sources {
3833        Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3834        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3835            .runtime_dir
3836            .as_ref()
3837            .and_then(|roots| roots.records.as_ref())
3838            .map(|records| records.path_fd().map(Arc::new))
3839            .transpose()
3840            .map_err(|error| {
3841                format!(
3842                    "sandbox: failed to open the records directory for Landlock scoping: {error}"
3843                )
3844            })?,
3845        None => None,
3846    };
3847
3848    // The private `/tmp` of a `MountNamespace` spawn, every path and buffer of it built here.
3849    let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3850        (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3851            build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3852        ),
3853        _ => None,
3854    };
3855
3856    // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
3857    // is a normal synchronous error here — it names the path and returns before any subprocess is
3858    // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
3859    // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
3860    // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
3861    // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
3862    let landlock_fds = if matches!(
3863        enforcement.tier,
3864        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3865    ) {
3866        Some(linux_enforce::open_landlock_fds(
3867            linux_enforce::LandlockFdRequest {
3868                accessible_workdir,
3869                landlock_grants: &enforcement.landlock_grants,
3870                device_grants: &landlock_device_grants(enforcement.tier),
3871                workdir_exec: enforcement.workdir_exec,
3872                abi: enforcement.landlock_abi,
3873                sealed: sealed_sources.as_ref(),
3874                temp_dir_fd,
3875                home_dir_fd,
3876                runtime_dir_fd,
3877                records_dir_fd,
3878                host_path_fds: host_path_fds
3879                    .iter()
3880                    .map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
3881                    .collect(),
3882            },
3883        )?)
3884    } else {
3885        None
3886    };
3887
3888    // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
3889    // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
3890    // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
3891    // thread that no longer exists — happens now instead, synchronously, where a failure names
3892    // itself. See `crate::sealed` for the plan/execute split.
3893    let sealed_spec = match &sealed_sources {
3894        Some(sources) => Some(build_sealed_root(
3895            accessible_workdir,
3896            sources,
3897            // Every optional mirror at its own path: pinned program entries first, so the bind
3898            // of a pinned binary's own folder lands inside the entry rather than under it; then
3899            // the bind directories, so a need inside one is already carried.
3900            &[
3901                enforcement.pinned_program_dirs.as_slice(),
3902                enforcement.sealed_bind_dirs.as_slice(),
3903                enforcement.sealed_need_mirrors.as_slice(),
3904            ]
3905            .concat(),
3906            &enforcement.staged_runtime_dirs,
3907            &host_path_fds,
3908            &enforcement.host_path_read_only_binds,
3909        )?),
3910        None => None,
3911    };
3912
3913    // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
3914    // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
3915    // nothing written (the parent's read then returns immediate EOF — zero cost on the success
3916    // path), while any `pre_exec` setup failure writes its real message here before the closure
3917    // returns `Err`.
3918    let mut diag_fds = [0i32; 2];
3919    // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
3920    // other precondition applies.
3921    let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3922    if rc != 0 {
3923        return Err(format!(
3924            "sandbox: pipe2() for enforcement diagnostics failed: {}",
3925            std::io::Error::last_os_error()
3926        ));
3927    }
3928    // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
3929    // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
3930    let (diag_read, diag_write) = unsafe {
3931        (
3932            OwnedFd::from_raw_fd(diag_fds[0]),
3933            OwnedFd::from_raw_fd(diag_fds[1]),
3934        )
3935    };
3936
3937    // The session's network namespace, created by the first command that needs it and joined by
3938    // every one after. Established here, in the parent and before the fork, so the egress proxy
3939    // is already serving the namespace's listeners when the command's first connection arrives,
3940    // and so a namespace that cannot be created fails this spawn with a legible error instead of
3941    // running the command anywhere else. The join handle moves into the closure, which keeps both
3942    // descriptors open until the `Command` is dropped.
3943    let observed_held_ports = enforcement.observed_held_ports();
3944    let netns = enforcement.session_netns.establish(
3945        &enforcement.egress_tcp_ports,
3946        crate::egress_proxy::EgressPolicy::new(
3947            enforcement.network_allow_rules.clone(),
3948            enforcement.network_allow_ips.clone(),
3949        ),
3950        observed_held_ports,
3951    )?;
3952
3953    // On an observed spawn, the socketpair the child sends its seccomp listener over. Both ends
3954    // are `CLOEXEC`: the child's end closes at `execve`, and the child closes the listener itself
3955    // before that.
3956    let (bind_listener_socket, bind_listener_handoff) = match observed_held_ports {
3957        Some(_) => {
3958            let (parent, handoff) = linux_enforce::bind_listener_socketpair()?;
3959            (Some(parent), Some(handoff))
3960        }
3961        None => (None, None),
3962    };
3963
3964    let tier = enforcement.tier;
3965    // Whether this spawn mounts the session's private `/proc`, and whether the `/tmp` mapping
3966    // already entered the private mount namespace it goes in.
3967    let private_proc =
3968        enforcement
3969            .installed_proc_view()
3970            .is_private()
3971            .then_some(linux_enforce::PrivateProc {
3972                mount_namespace_entered: tmp_map_spec.is_some(),
3973            });
3974    // Copied out before the `move` closure below takes ownership of everything it touches, the
3975    // same clone-before-move shape `tier` and the Landlock fds already use.
3976    let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3977    let fd_hygiene_fallback = enforcement.fd_hygiene_fallback;
3978    // Same clone-before-move shape. `HostResourceLimits` and `NprocBound` are `Copy`, and the
3979    // `RLIMIT_NPROC` decision is made here because its baseline is a `/proc` walk; the cgroup scope
3980    // is an `Arc` whose `cgroup.procs` descriptor was opened in the parent at scope-creation time,
3981    // so the child performs no path lookup to join it.
3982    let resource_limits = enforcement.resource_limits;
3983    let nproc = enforcement.nproc_bound();
3984    let cgroup_scope = enforcement.cgroup_scope.clone();
3985
3986    // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
3987    // narrow pre_exec window where only async-signal-safe operations are permitted. In order, it
3988    // performs: without a cgroup scope, the `RLIMIT_NPROC` `getrlimit`/`setrlimit` pair of
3989    // `resources::apply_nproc_bound`; with one, one `write` to the `cgroup.procs` descriptor the
3990    // parent opened; two `setns` calls into the session's namespaces; a third `setns` into its PID
3991    // namespace and a `fork`, after which the forked-from process is the relay — `sigprocmask`,
3992    // `close_range` (or `getrlimit` and a `close` loop where it is refused), `waitpid`,
3993    // `sigaction`, `kill` and `_exit`, never returning — and the child
3994    // continues as the command with one `prctl(PR_SET_PDEATHSIG)` and one `getppid`; on a private
3995    // process view the `unshare`/`mount`/`open` of `proc_view::mount_private_proc`; on a
3996    // `MountNamespace` spawn
3997    // the `unshare`/`mount`/`open`/`mkdirat`/`chdir` sequence of `temp_dir::map_private_tmp`, and
3998    // on `KernelSealed` the `unshare`/`open`/`write`/`mount`/`pivot_root` sequence of
3999    // `sealed::construct_composed_root`, both over paths and buffers built here; the
4000    // `close_range` fd-hygiene call; the capability-dropping `prctl`/`capset` sequence
4001    // (bounding-set drop, capset clear, and the explicit `no_new_privs` `prctl`) plus one
4002    // allocation-free `open`/`read`/`close` of `/proc/sys/kernel/cap_last_cap`; libseccomp filter
4003    // construction/load (kernel syscalls), and on an observed spawn one `sendmsg` of the filter's
4004    // listener over the socketpair end, from stack buffers, then one `close` of the listener; on `KernelFull`/`KernelSealed` Landlock ruleset
4005    // construction/`restrict_self` against already-open fds (also kernel syscalls); and last, the
4006    // `getrlimit`/`setrlimit` pairs of `resources::apply_per_process_rlimits`. No `canonicalize()`, no
4007    // path lookup beyond those prepared paths and `cap_last_cap`, no locks beyond what those
4008    // syscalls need. On any failure it writes the real error message to the CLOEXEC diagnostic
4009    // pipe (best-effort, bounded, raw `write` loop) before returning `Err`. `diag_write`, the
4010    // namespace join handle, the Landlock fds and the bind-listener socketpair end are moved in,
4011    // and are `CLOEXEC`, so none of them reaches the exec'd program.
4012    unsafe {
4013        command.pre_exec(move || {
4014            // `RLIMIT_NPROC` first: the composed root's `unshare(CLONE_NEWUSER)` below fixes the
4015            // session namespace's task bound at this child's limit as of that call, and a later
4016            // `setrlimit` would bound only the new namespace's own tasks. It is set only without
4017            // a cgroup scope (`NprocBound::Headroom`) and creates no descriptor.
4018            if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
4019                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4020                return Err(error);
4021            }
4022            if let Some(scope) = cgroup_scope.as_ref() {
4023                if let Err(error) = scope.join_current_process() {
4024                    linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4025                    return Err(error);
4026                }
4027            }
4028
4029            let (user_ns_fd, net_ns_fd) = netns.raw_fds();
4030            if let Err(error) = linux_enforce::child_install_enforcement(
4031                tier,
4032                linux_enforce::SessionNamespaces {
4033                    user_ns_fd,
4034                    net_ns_fd,
4035                    pid_ns_fd: netns.pid_ns_fd(),
4036                },
4037                private_proc,
4038                tmp_map_spec.as_ref(),
4039                sealed_spec.as_ref(),
4040                landlock_fds.as_ref(),
4041                unix_sockets_allowed,
4042                bind_listener_handoff.as_ref(),
4043                fd_hygiene_fallback,
4044            ) {
4045                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4046                return Err(error);
4047            }
4048
4049            // The per-process rlimits come last, as the final call before `execve`. They cannot
4050            // come first: every descriptor the steps above create (the `/tmp` mapping's `O_PATH`
4051            // fds, the composed root's `/proc/self/*_map` writes and `MkFile` steps,
4052            // `cap_last_cap`, the Landlock ruleset) must be numbered below `RLIMIT_NOFILE`, and
4053            // this child holds every descriptor `mur` had open at the fork, so the lowest free
4054            // number can be above a modest `max_open_files`. Last still bounds the exec'd program
4055            // from its first instruction: a step above that fails returns `Err`, which aborts the
4056            // exec, so no capsule code runs in this child; a failing `setrlimit` aborts it the
4057            // same way; and `prlimit64`/`getrlimit`/`setrlimit` are in
4058            // `SECCOMP_SYSCALL_ALLOWLIST`, and lowering a limit needs no capability, so neither
4059            // the filter nor the capability drop stops them.
4060            if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
4061                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4062                return Err(error);
4063            }
4064            Ok(())
4065        });
4066    }
4067
4068    Ok(SupervisorHandle::Linux {
4069        diag_read,
4070        bind_listener: bind_listener_socket,
4071    })
4072}
4073
4074/// Which fixed device set Landlock grants, keyed on the tier.
4075///
4076/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
4077/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
4078/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
4079/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
4080/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
4081///
4082/// The parent opens these paths on the *host*, before the namespace exists; because the composed
4083/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
4084/// are the inodes the capsule reaches.
4085#[cfg(target_os = "linux")]
4086fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
4087    match tier {
4088        EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
4089            .iter()
4090            .map(|device| CapsuleDeviceGrant {
4091                path: device.path,
4092                writable: device.writable,
4093            })
4094            .collect(),
4095        _ => CAPSULE_DEVICE_GRANTS.to_vec(),
4096    }
4097}
4098
4099/// The `/tmp` mapping of one `MountNamespace` spawn: the folder held by `folder_fd` bound over
4100/// canonical `/tmp`, and every path beneath the host's `/tmp` the command still needs re-exposed
4101/// at its own path.
4102///
4103/// Those paths are the accessible workdir and the session's home folder, bound read-write, and the
4104/// runtime and records directories and every Landlock grant path, which already carries the
4105/// `shell.allow` closure and the interpreter and staged runtimes, bound read-only. Landlock adds
4106/// the folder's rights to everything bound beneath it, which are the workdir's own; a read-only
4107/// bind is what keeps them from writing the rest. See [`crate::temp_dir::reexposed_paths`].
4108#[cfg(target_os = "linux")]
4109fn build_tmp_map_spec(
4110    enforcement: &ShellEnforcement,
4111    folder_fd: &std::os::fd::OwnedFd,
4112    accessible_workdir: &Path,
4113) -> Result<crate::temp_dir::TmpMapSpec, String> {
4114    let folder = enforcement.temp_dir_path().ok_or_else(|| {
4115        format!(
4116            "{} this enforcement holds none",
4117            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
4118        )
4119    })?;
4120    let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
4121        format!(
4122            "{} fstat of the temporary folder failed: {errno}",
4123            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
4124        )
4125    })?;
4126    let canonical_tmp = crate::temp_dir::canonical_tmp();
4127    let read_only = [
4128        enforcement.runtime_dir_path(),
4129        enforcement.records_dir_path(),
4130    ]
4131    .into_iter()
4132    .flatten()
4133    .chain(
4134        enforcement
4135            .landlock_grants
4136            .iter()
4137            .map(|grant| grant.path.as_path()),
4138    );
4139    let reexposed = crate::temp_dir::reexposed_paths(
4140        &canonical_tmp,
4141        std::iter::once(accessible_workdir).chain(enforcement.home_dir_path()),
4142        read_only,
4143    );
4144    crate::temp_dir::TmpMapSpec::new(
4145        folder,
4146        identity,
4147        &canonical_tmp,
4148        &reexposed,
4149        accessible_workdir,
4150    )
4151}
4152
4153/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
4154/// of the host's into the session's `sealed-etc/` directory, returning their host paths in
4155/// [`crate::sealed::SEALED_ETC_PATHS`] order.
4156///
4157/// `sealed-etc/` sits beside the runtime directory, outside every workdir, and no capsule command
4158/// is granted it. The account the files name is the home folder's owner, the identity the
4159/// capsule's subprocesses run as, with the session's home folder as its home directory.
4160///
4161/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
4162/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
4163/// nothing the parent could have done for it.
4164///
4165/// The rewrite is in place ([`crate::workdir_fs::WorkdirRoot::overwrite_in_place`] truncates; it
4166/// does not replace): Landlock grants the file by inode and the composed root binds it by path, so
4167/// replacing the file would leave `/etc/passwd` bound but denied (`EACCES`).
4168#[cfg(target_os = "linux")]
4169fn ensure_sealed_identity_files(
4170    sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
4171    home_root: &crate::workdir_fs::WorkdirRoot,
4172) -> Result<Vec<PathBuf>, String> {
4173    // The home folder was created by this process, so its owner is the identity the capsule's
4174    // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
4175    let (uid, gid) = home_root.owner().map_err(|error| {
4176        format!(
4177            "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
4178        )
4179    })?;
4180    // The same string `build_shell_env` puts in `$HOME`: the home folder's own absolute path,
4181    // which the composed root binds at that same path.
4182    let home = home_root.path().to_string_lossy().into_owned();
4183    let identity = crate::sealed::SealedAccountIdentity {
4184        uid,
4185        gid,
4186        home: &home,
4187    };
4188
4189    let mut written = Vec::new();
4190    for entry in crate::sealed::SEALED_ETC_PATHS {
4191        let Some(file) = entry.synthetic else {
4192            continue;
4193        };
4194        sealed_etc_root
4195            .overwrite_in_place(
4196                file.file_name(),
4197                file.render(&identity)?.as_bytes(),
4198                crate::workdir_fs::DEFAULT_FILE_MODE,
4199            )
4200            .map_err(|error| {
4201                format!(
4202                    "sealed: failed to write the synthetic {}: {error}",
4203                    entry.path
4204                )
4205            })?;
4206        written.push(crate::sealed::synthetic_etc_source(
4207            sealed_etc_root.path(),
4208            file,
4209        ));
4210    }
4211    Ok(written)
4212}
4213
4214/// One host path's descriptor, opened in the parent at each spawn: its Landlock rule's fd on both
4215/// Landlock tiers and, on `KernelSealed`, the identity its bind verifies.
4216#[cfg(target_os = "linux")]
4217pub(crate) struct HostPathFd {
4218    fd: Arc<std::os::fd::OwnedFd>,
4219    path: PathBuf,
4220    read_write: bool,
4221}
4222
4223/// The descriptors a composed root's runtime-provided binds are verified against and its Landlock
4224/// rules are taken on, opened in the parent before the fork.
4225#[cfg(target_os = "linux")]
4226pub(crate) struct SealedSourceFds {
4227    /// The session's temporary folder, reopened from its root's held descriptor: the `/tmp`
4228    /// rule's fd.
4229    tmp_store: Arc<std::os::fd::OwnedFd>,
4230    /// The folder's host path, which the child opens and verifies against [`Self::tmp_store`].
4231    tmp_store_path: PathBuf,
4232    /// The session's home folder, reopened from its root's held descriptor, and its absolute
4233    /// path: its rule's fd and its read-write bind at that same path.
4234    home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
4235    /// The runtime directory, reopened from its root's held descriptor: its rule's fd.
4236    runtime_dir: Arc<std::os::fd::OwnedFd>,
4237    /// The runtime directory's absolute path, which the bind reproduces inside the root and the
4238    /// child verifies against [`Self::runtime_dir`].
4239    runtime_dir_path: PathBuf,
4240    /// The records directory, reopened from its root's held descriptor, and its absolute path:
4241    /// its rule's fd and its read-only bind, on the runtime directory's terms. `None` for an
4242    /// enforcement held outside a session.
4243    records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
4244    /// `sealed-etc/`'s host path.
4245    etc_dir: PathBuf,
4246    /// The synthetic `/etc` files, in [`crate::sealed::SEALED_ETC_PATHS`] order.
4247    identity_files: Vec<PathBuf>,
4248}
4249
4250#[cfg(target_os = "linux")]
4251impl SealedSourceFds {
4252    /// Writes the synthetic `/etc` files and takes every descriptor, with `temp_dir_fd` and
4253    /// `home_dir_fd` the descriptors `prepare_enforcement` took on the session's temporary and home
4254    /// folders. A sealed root cannot be composed for an enforcement that holds no runtime
4255    /// directory, no temporary folder or no home folder.
4256    fn open(
4257        enforcement: &ShellEnforcement,
4258        temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
4259        home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
4260    ) -> Result<Self, String> {
4261        let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
4262            "sealed: a composed root needs the session's runtime directory, and this \
4263             enforcement holds none"
4264                .to_string()
4265        })?;
4266        let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
4267            return Err(
4268                "sealed: a composed root binds the session's temporary folder at /tmp, \
4269                        and this enforcement holds none"
4270                    .to_string(),
4271            );
4272        };
4273        let tmp_store = Arc::clone(tmp_store);
4274        let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
4275            return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
4276        };
4277        let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
4278        let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
4279        let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
4280            format!("sealed: failed to open the runtime directory for its bind: {error}")
4281        })?);
4282        let records_dir = roots
4283            .records
4284            .as_ref()
4285            .map(|records| {
4286                records
4287                    .path_fd()
4288                    .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
4289                    .map_err(|error| {
4290                        format!(
4291                            "sealed: failed to open the records directory for its bind: {error}"
4292                        )
4293                    })
4294            })
4295            .transpose()?;
4296        Ok(Self {
4297            tmp_store,
4298            tmp_store_path: temp.path().to_path_buf(),
4299            home_dir,
4300            runtime_dir,
4301            runtime_dir_path: roots.runtime.path().to_path_buf(),
4302            records_dir,
4303            etc_dir: roots.sealed_etc.path().to_path_buf(),
4304            identity_files,
4305        })
4306    }
4307
4308    /// `(st_dev, st_ino)` of `fd`, which a bind's source must still have in the child.
4309    fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
4310        crate::workdir_fs::fd_identity(fd)
4311            .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
4312    }
4313}
4314
4315/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
4316/// host layout, and lower the plan into the C-string form the forked child executes.
4317///
4318/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
4319/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
4320/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
4321/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
4322/// declared-vs-achieved mismatch.
4323///
4324/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
4325/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
4326/// error would return `Err(String)`, which converts to the ordinary, retryable
4327/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
4328/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
4329/// classification for a capsule that did not get a runtime tree it declared.
4330#[cfg(target_os = "linux")]
4331fn build_sealed_root(
4332    accessible_workdir: &Path,
4333    sources: &SealedSourceFds,
4334    extra_read_only: &[PathBuf],
4335    staged_runtime_read_only: &[PathBuf],
4336    host_paths: &[HostPathFd],
4337    host_path_read_only: &[PathBuf],
4338) -> Result<crate::sealed::SealedRootSpec, String> {
4339    use crate::sealed;
4340
4341    // The workdir must be absolute for its path to mean the same thing inside the composed root,
4342    // and `launch_session` always creates it as one — but the composed root's whole design rests
4343    // on it, so it is checked rather than assumed.
4344    if !accessible_workdir.is_absolute() {
4345        return Err(format!(
4346            "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
4347             its own absolute path",
4348            accessible_workdir.display()
4349        ));
4350    }
4351
4352    let host_path_binds = host_paths
4353        .iter()
4354        .map(|host_path| {
4355            SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
4356                path: host_path.path.clone(),
4357                identity,
4358                read_only: !host_path.read_write,
4359            })
4360        })
4361        .collect::<Result<Vec<_>, _>>()?;
4362    let host_path_dirs: Vec<PathBuf> = host_paths
4363        .iter()
4364        .map(|host_path| host_path.path.clone())
4365        .collect();
4366    let base = sealed::choose_root_base(
4367        accessible_workdir,
4368        &host_path_dirs,
4369        sealed::SEALED_ROOT_BASE_CANDIDATES,
4370        |path| path.is_dir(),
4371    )
4372    .ok_or_else(|| {
4373        format!(
4374            "sealed: no usable base directory for the composed root; none of {:?} exists outside \
4375             the session workdir's own path and every host path's",
4376            sealed::SEALED_ROOT_BASE_CANDIDATES
4377        )
4378    })?;
4379
4380    let plan = sealed::plan_composed_root(
4381        accessible_workdir,
4382        &sealed::SealedBindSources {
4383            tmp_store: (
4384                &sources.tmp_store_path,
4385                SealedSourceFds::identity_of(&sources.tmp_store)?,
4386            ),
4387            etc_dir: &sources.etc_dir,
4388            runtime_dir: Some((
4389                &sources.runtime_dir_path,
4390                SealedSourceFds::identity_of(&sources.runtime_dir)?,
4391            )),
4392            records_dir: sources
4393                .records_dir
4394                .as_ref()
4395                .map(|(fd, path)| {
4396                    SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
4397                })
4398                .transpose()?,
4399            home_dir: Some((
4400                &sources.home_dir.1,
4401                SealedSourceFds::identity_of(&sources.home_dir.0)?,
4402            )),
4403            host_paths: &host_path_binds,
4404            host_path_read_only,
4405        },
4406        &base,
4407        extra_read_only,
4408        staged_runtime_read_only,
4409        &sealed::RealHostLayout,
4410    );
4411    sealed::build_sealed_root_spec(&plan)
4412}
4413
4414/// Linux-only mechanics: the `pre_exec` enforcement sequence, seccomp filter construction and
4415/// Landlock application.
4416///
4417/// No decision here rests on `libseccomp`'s notify facility. `connect`/`sendto` are enforced by
4418/// the network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which
4419/// decide from kernel-held state. A `Notify` rule that decided would reintroduce a read of the
4420/// calling task's memory on the hot path, where the value read can change between the decision
4421/// and the kernel's own dereference.
4422///
4423/// The one `Notify` rule is `bind`'s on an observed spawn, and it decides nothing: the session's
4424/// [`crate::held_ports::BindObserver`] records the port and answers every notification with
4425/// `SECCOMP_USER_NOTIF_FLAG_CONTINUE`, so the kernel performs the bind exactly as it would under
4426/// `Allow`, and what the observer read can only change which line a later shell result carries.
4427#[cfg_attr(target_os = "linux", allow(unsafe_code))]
4428#[cfg(target_os = "linux")]
4429mod linux_enforce {
4430    use std::io;
4431    use std::os::fd::{AsFd, OwnedFd, RawFd};
4432    use std::path::Path;
4433
4434    use landlock::{make_bitflags, AccessFs, BitFlags};
4435
4436    use super::{EnforcementTier, LandlockGrant};
4437
4438    /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
4439    /// naming the failed step is far shorter than this; the bound just keeps the best-effort
4440    /// `write`/`read` loops trivially terminating.
4441    const MAX_DIAG_LEN: usize = 1024;
4442
4443    /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
4444    /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
4445    /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
4446    /// never a path to re-open there.
4447    pub(super) struct OpenLandlockGrant {
4448        fd: OwnedFd,
4449        list_dir: bool,
4450        executable: bool,
4451    }
4452
4453    /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
4454    /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
4455    /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
4456    /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
4457    /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
4458    /// their bits must not be confusable.
4459    pub(super) struct OpenDeviceGrant {
4460        fd: OwnedFd,
4461        writable: bool,
4462    }
4463
4464    /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
4465    /// workdir's fd and the session's temporary folder's (both scoped by
4466    /// [`workdir_access_rights`]), each successfully-opened grant fd, and each
4467    /// successfully-opened fixed-device fd.
4468    /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
4469    /// against these fds without performing a single `open()`.
4470    pub(super) struct LandlockChildFds {
4471        workdir_fd: OwnedFd,
4472        /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
4473        /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
4474        /// but its argument — has the whole workdir rule decided for it before fork.
4475        workdir_exec: bool,
4476        /// The Landlock ABI the ruleset is built at, decided in the parent from the session's
4477        /// [`super::HostProbe`] (see [`super::ShellEnforcement::landlock_abi`]) so the child reads
4478        /// no host state of its own. `None` builds at ABI v1, the narrowest set.
4479        abi: Option<u8>,
4480        /// The session's temporary folder, `tmp/` in its records directory, on both Landlock
4481        /// tiers whenever the enforcement holds one, mapped onto `/tmp` or not. `None` outside a
4482        /// session. On `KernelSealed` the same descriptor is the composed root's `/tmp` bind
4483        /// source.
4484        ///
4485        /// It needs a rule of its own on every mapped tier: Landlock resolves a path by walking
4486        /// dentries up to each mount root, so an access to `/tmp/x` is matched against the folder
4487        /// (the bind's root) and then against the mountpoint `/tmp` — never against the records
4488        /// directory or the workdir. It carries the workdir's own right-set, `Execute` axis
4489        /// included, so `workdir_exec: false` forbids running anything written there.
4490        temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4491        /// The session's home folder, `home/` in its records directory, on exactly
4492        /// [`Self::temp_dir_fd`]'s terms: the workdir's rights, on both Landlock tiers, whenever the
4493        /// enforcement holds one. On `KernelSealed` the same descriptor is the read-write bind's
4494        /// source.
4495        home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4496        /// The synthetic `/etc/passwd`/`/etc/group` in the session's `sealed-etc/`, opened on
4497        /// `KernelSealed` and empty on every other tier.
4498        ///
4499        /// Exactly the [`Self::temp_dir_fd`] problem, one directory over. The grant list already
4500        /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
4501        /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
4502        /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
4503        /// at the bound file's own inode, so without these fds both account databases would be
4504        /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
4505        ///
4506        /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
4507        /// nothing inside a capsule has any business editing an account database.
4508        sealed_identity_fds: Vec<OwnedFd>,
4509        /// The session's runtime directory, reopened from its root's held descriptor: `ReadFile`
4510        /// and `ReadDir` and nothing else, so every command reads `MURMUR.md`, `task.md`, plans
4511        /// and installed tools and changes none of them. `None` outside a session. On
4512        /// `KernelSealed` the same descriptor is the read-only bind's source.
4513        runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4514        /// The session's records directory, reopened from its root's held descriptor, granted on
4515        /// [`Self::runtime_dir_fd`]'s terms: every command reads the trace and logs and changes
4516        /// none of them. `None` outside a session. On `KernelSealed` the same descriptor is the
4517        /// read-only bind's source.
4518        records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4519        /// Every host path's descriptor and whether it is `read-write`, in config order: one rule
4520        /// each, with [`host_path_access_rights`]. Opened fail-closed by `prepare_enforcement`.
4521        host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
4522        grants: Vec<OpenLandlockGrant>,
4523        devices: Vec<OpenDeviceGrant>,
4524    }
4525
4526    impl LandlockChildFds {
4527        /// The temporary folder's fd, for the tier-gate test. Keeps the struct's fields private
4528        /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
4529        #[cfg(test)]
4530        pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
4531            self.temp_dir_fd.as_deref()
4532        }
4533
4534        /// The home folder's fd, for the tier-gate test, on the same terms.
4535        #[cfg(test)]
4536        pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
4537            self.home_dir_fd.as_deref()
4538        }
4539
4540        /// The runtime directory's fd, for the tier-gate test, on the same terms.
4541        #[cfg(test)]
4542        pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
4543            self.runtime_dir_fd.as_deref()
4544        }
4545
4546        /// The records directory's fd, for the tier-gate test, on the same terms.
4547        #[cfg(test)]
4548        pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
4549            self.records_dir_fd.as_deref()
4550        }
4551
4552        /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
4553        /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
4554        #[cfg(test)]
4555        pub(super) fn sealed_identity_fd_count(&self) -> usize {
4556            self.sealed_identity_fds.len()
4557        }
4558    }
4559
4560    /// The Landlock rights granted on the capsule **workdir's own** `PathBeneath` rule,
4561    /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
4562    /// declared `capabilities.filesystem.workdir_exec: true`.
4563    ///
4564    /// Spelled out variant by variant rather than `AccessFs::from_all` for two reasons: `from_all`
4565    /// hides what is actually handed to the capsule, and four rights are withheld from this
4566    /// constant. It is the set for [`super::LANDLOCK_TARGET_ABI`]; `apply_landlock_scope` masks it
4567    /// to the ABI the ruleset is built at, so on an older kernel the rights that ABI does not know
4568    /// simply drop out.
4569    ///
4570    /// Two rights are granted from an ABI later than v1:
4571    ///
4572    ///   - `Refer` (ABI v2, Linux 5.19) — moving and hard-linking a file between two folders.
4573    ///     Landlock refuses every cross-folder `rename(2)`/`link(2)` with `EXDEV` unless the ruleset
4574    ///     handles `Refer` and both folders grant it, so without it every tool that renames a
4575    ///     finished file into place from a sibling folder fails in the workdir. It reaches nothing
4576    ///     outside: a move or link needs `Refer` on both the source and the destination folder, no
4577    ///     rule outside the workdir and the session's temporary folder carries it, and Landlock
4578    ///     also refuses any move that would leave the file with more rights at its destination
4579    ///     than its source.
4580    ///   - `Truncate` (ABI v3, Linux 6.2) — `truncate(2)`, `ftruncate(2)` and `open(O_TRUNC)`.
4581    ///     Below v3 Landlock does not mediate truncation at all, so a capsule could zero any host
4582    ///     file its uid can write. Handling it denies that everywhere except the rules that grant
4583    ///     it, which are exactly the ones already carrying `WriteFile`: this one and the writable
4584    ///     devices.
4585    ///
4586    ///   - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
4587    ///     makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
4588    ///     on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
4589    ///     cannot run under any name — including an allowlisted basename, the exact bypass a prior
4590    ///     release shipped. The cost: a binary the capsule legitimately compiled in its workdir
4591    ///     cannot run either, which is what `workdir_exec: true` buys back.
4592    ///   - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
4593    ///     whole scope. A capsule running as root could `mknod` a node for the host's own disk
4594    ///     (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
4595    ///     permits, because the new inode lives beneath a granted path — then `open()` it and
4596    ///     read the raw host filesystem underneath every other restriction. No known workload
4597    ///     creates device nodes in its working tree.
4598    ///   - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
4599    ///     filesystem object with a kernel-side identity beyond a regular file), but unlike the
4600    ///     two device rights this one is a genuine open question: some build tooling and some
4601    ///     language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
4602    ///     that breaks those is not a fix. The manual acceptance procedure in
4603    ///     `docs/content/reference/workdir-device-node-manual-verification.md` carries an
4604    ///     explicit unix-socket scenario for exactly this; if the team's
4605    ///     real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
4606    ///
4607    /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
4608    /// and a FIFO carries none of the raw-device risk above.
4609    ///
4610    /// Because `apply_landlock_scope`'s `handle_access` declares the *full* `from_all` set of the
4611    /// ABI it builds at, a right this rule withholds is not merely "not extra-granted" — it
4612    /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
4613    /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
4614    /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
4615    /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
4616    /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
4617    /// nowhere the capsule writes.
4618    pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4619        WriteFile
4620            | ReadFile
4621            | ReadDir
4622            | RemoveDir
4623            | RemoveFile
4624            | MakeDir
4625            | MakeReg
4626            | MakeFifo
4627            | MakeSym
4628            | Refer
4629            | Truncate
4630    });
4631
4632    /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
4633    /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
4634    ///
4635    /// A function rather than a second constant so the two cases cannot drift: adding a right to
4636    /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
4637    /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
4638    /// The whole point of the default is that the workdir grant is no longer name-based at all.
4639    pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4640        if workdir_exec {
4641            WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4642        } else {
4643            WORKDIR_ACCESS_RIGHTS_NO_EXEC
4644        }
4645    }
4646
4647    /// A host path's `PathBeneath` rights: `ReadFile | ReadDir` for `read`, and
4648    /// [`workdir_access_rights`]`(false)` for `read-write`.
4649    /// Never `Execute`, whatever `workdir_exec` grants the workdir: a host path makes files
4650    /// reachable, never runnable, so `shell.allow` stays the whole list of what can run.
4651    pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
4652        if read_write {
4653            workdir_access_rights(false)
4654        } else {
4655            AccessFs::ReadFile | AccessFs::ReadDir
4656        }
4657    }
4658
4659    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock.
4660    ///
4661    /// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the version and
4662    /// creates nothing — no ruleset fd and no domain — so unlike
4663    /// [`probe_landlock_full_access`] it is safe to call in-process. A kernel built without
4664    /// Landlock, or with it disabled at boot, fails it with `ENOSYS`/`EOPNOTSUPP`.
4665    pub(super) fn kernel_landlock_abi() -> Option<u8> {
4666        const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4667        // SAFETY: with a null attribute pointer, a zero size and only the VERSION flag set, the
4668        // kernel reads no memory and allocates no fd; the call has no side effect to undo.
4669        let version = unsafe {
4670            libc::syscall(
4671                libc::SYS_landlock_create_ruleset,
4672                std::ptr::null::<libc::c_void>(),
4673                0usize,
4674                LANDLOCK_CREATE_RULESET_VERSION,
4675            )
4676        };
4677        if version <= 0 {
4678            return None;
4679        }
4680        Some(u8::try_from(version).unwrap_or(u8::MAX))
4681    }
4682
4683    /// Probes real Landlock support by building a ruleset granting full access to `/` and
4684    /// calling `restrict_self()` **in a forked child**, so the host process is never placed
4685    /// into a Landlock domain by a mere capability check.
4686    ///
4687    /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
4688    /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
4689    /// mount family to the task and every descendant it will ever have, which is how Landlock
4690    /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
4691    /// before the sealed probe, and a session's probe is taken before any fork that would
4692    /// build a composed root, so restricting in-process poisons `sealed` on every host and every
4693    /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
4694    /// to suggest the runtime did it to itself.
4695    ///
4696    /// The ruleset is built in the parent because creating one and adding rules restricts
4697    /// nothing; only `restrict_self` applies it. That keeps everything the child does after
4698    /// the fork down to a `prctl` and one syscall.
4699    pub(super) fn probe_landlock_full_access() -> Option<bool> {
4700        use landlock::{
4701            Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4702            RulesetCreatedAttr, RulesetStatus, ABI,
4703        };
4704
4705        // The child reports which of the three outcomes it reached through its exit status;
4706        // nothing else crosses the fork.
4707        const PROBE_FULLY_ENFORCED: i32 = 0;
4708        const PROBE_PARTIAL: i32 = 1;
4709        const PROBE_FAILED: i32 = 2;
4710
4711        // ABI v1 on purpose: this decides whether a Landlock tier is available at all, which v1
4712        // answers on every Landlock kernel. The ABI a capsule's ruleset is built at is read
4713        // separately, by `kernel_landlock_abi`.
4714        let abi = ABI::V1;
4715        let access_all = AccessFs::from_all(abi);
4716
4717        let root_fd = PathFd::new("/").ok()?;
4718
4719        let ruleset = Ruleset::default()
4720            .set_compatibility(CompatLevel::BestEffort)
4721            .handle_access(access_all)
4722            .ok()?
4723            .create()
4724            .ok()?
4725            .add_rule(PathBeneath::new(root_fd, access_all))
4726            .ok()?;
4727
4728        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
4729        // confines itself to async-signal-safe work. Everything this child does is the
4730        // `restrict_self` syscall pair and `_exit`; the allocating part of building the
4731        // ruleset already happened above, in the parent.
4732        let pid = unsafe { libc::fork() };
4733        if pid < 0 {
4734            return None;
4735        }
4736        if pid == 0 {
4737            let code = match ruleset.restrict_self() {
4738                Ok(status) => {
4739                    if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4740                        PROBE_FULLY_ENFORCED
4741                    } else {
4742                        PROBE_PARTIAL
4743                    }
4744                }
4745                Err(_) => PROBE_FAILED,
4746            };
4747            // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4748            // which is what keeps the parent's state untouched.
4749            unsafe { libc::_exit(code) }
4750        }
4751
4752        let mut wait_status: libc::c_int = 0;
4753        // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
4754        let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4755        if waited < 0 || !libc::WIFEXITED(wait_status) {
4756            return None;
4757        }
4758        match libc::WEXITSTATUS(wait_status) {
4759            PROBE_FULLY_ENFORCED => Some(true),
4760            PROBE_PARTIAL => Some(false),
4761            // A `restrict_self` that errored tells us nothing either way, which is the same
4762            // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
4763            _ => None,
4764        }
4765    }
4766
4767    /// Runs inside the forked child, pre-exec: joins the session's user and network namespaces,
4768    /// enters its PID namespace through a relay (this process waits outside it and never returns;
4769    /// the command continues in a child forked into it), maps `/tmp` onto the session's
4770    /// temporary folder when `tmp_map_spec` is given, mounts the session's private `/proc` when
4771    /// `private_proc` is given, strips the child's Linux capabilities, installs the seccomp filter
4772    /// (the `socket()` domain denials keyed on `unix_sockets_allowed`, over a default-deny syscall
4773    /// allowlist), and (on `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope —
4774    /// which is also what enforces `capabilities.shell.allow`. Returning `Err` here aborts the
4775    /// exec (std's `Command` machinery propagates it back to the parent's `.spawn()` call as an
4776    /// `io::Error`) — the fail-closed path for setup failures that happen after fork.
4777    #[allow(clippy::too_many_arguments)]
4778    pub(super) fn child_install_enforcement(
4779        tier: EnforcementTier,
4780        namespaces: SessionNamespaces,
4781        private_proc: Option<PrivateProc>,
4782        tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4783        sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4784        landlock_fds: Option<&LandlockChildFds>,
4785        unix_sockets_allowed: bool,
4786        bind_listener: Option<&BindListenerHandoff>,
4787        fd_hygiene_fallback: bool,
4788    ) -> io::Result<()> {
4789        // FIRST, on every kernel tier: join the session's network namespace. It has to precede
4790        // everything below it for three separate reasons:
4791        //
4792        //   * `SECCOMP_MUST_STAY_DENIED` denies `setns`, so the filter installed further down
4793        //     closes the only window in which the join can happen — the same argument the
4794        //     composed root makes for `unshare`.
4795        //   * joining the user namespace hands this child every capability *inside* it, and
4796        //     `drop_all_capabilities` (below) strips them again before `execve`. Joining after
4797        //     that drop would fail: entering the network namespace needs `CAP_SYS_ADMIN` there.
4798        //   * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests its user
4799        //     namespace inside the session's. A task in a descendant user namespace holds no
4800        //     capability in the ancestor, so the capsule cannot reconfigure the network namespace
4801        //     it is confined to — which only holds if the join comes first.
4802        //
4803        // There is no path that skips it and falls back: a host that cannot create the namespace
4804        // was refused at launch (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`), and a
4805        // spawn whose namespace could not be established failed before the fork.
4806        //
4807        // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
4808        // documents itself as running in, and the one `join_session_netns` requires. Both
4809        // descriptors are held open by the join handle the `pre_exec` closure owns.
4810        unsafe {
4811            crate::network_namespace::join_session_netns(
4812                namespaces.user_ns_fd,
4813                namespaces.net_ns_fd,
4814            )
4815        }
4816        .map_err(|error| {
4817            io::Error::other(format!(
4818                "sandbox: joining this session's network namespace failed: {error}"
4819            ))
4820        })?;
4821
4822        // NEXT: the session's PID namespace. This process becomes the command's relay, which
4823        // stays outside the namespace and never returns from here; the process that does return
4824        // is its child, forked into the namespace, and every step below runs in it. `setns` of
4825        // a PID namespace needs `CAP_SYS_ADMIN` in the user namespace owning it, which the join
4826        // above just handed out, and the seccomp filter below denies `setns`.
4827        //
4828        // SAFETY: post-`fork()`, pre-exec child context, inside the session's user namespace; the
4829        // descriptor is held open by the join handle the `pre_exec` closure owns.
4830        unsafe { enter_session_pid_namespace(namespaces.pid_ns_fd) }?;
4831
4832        // NEXT, and only on a `MountNamespace` spawn: a private mount namespace, owned by the
4833        // session's user namespace, in which the session's temporary folder is bound over `/tmp`.
4834        // Here for the composed root's reason: the capabilities the join above handed out are
4835        // still held, and the seccomp filter below denies `unshare` and `mount`. A mapping that
4836        // cannot be built fails the spawn; nothing runs with an unmapped `/tmp` instead.
4837        if let Some(spec) = tmp_map_spec {
4838            #[cfg(test)]
4839            if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4840                return Err(io::Error::other(format!(
4841                    "{} (forced by test seam)",
4842                    spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4843                )));
4844            }
4845            // SAFETY: post-`fork()`, pre-exec child context, after `join_session_netns` handed
4846            // this child every capability in the session's user namespace and before the seccomp
4847            // filter — the window `map_private_tmp` requires. Every path and buffer it reads was
4848            // built in the parent and is borrowed from the `pre_exec` closure.
4849            unsafe { crate::temp_dir::map_private_tmp(spec) }
4850                .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4851        }
4852
4853        // NEXT, when the session's view is private: a `procfs` of the session's PID namespace
4854        // over `/proc`, in the private mount namespace the `/tmp` mapping entered or one entered
4855        // here. Before the composed root, whose `/proc` step then binds this mount, and before the
4856        // filter, which denies `unshare` and `mount`. The staging probe mounted one, so a mount
4857        // that fails here fails the spawn; nothing runs with the host's `/proc` instead.
4858        let proc_fd = match private_proc {
4859            None => None,
4860            Some(private_proc) => {
4861                #[cfg(test)]
4862                if super::FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.get()) {
4863                    return Err(io::Error::other(format!(
4864                        "{} (forced by test seam)",
4865                        crate::proc_view::describe_mount_failure(
4866                            crate::proc_view::ProcMountStep::MountProc,
4867                            libc::EPERM
4868                        )
4869                    )));
4870                }
4871                // SAFETY: post-`fork()`, pre-exec child context, inside the session's user and
4872                // PID namespaces with the capabilities the join handed out, before the filter.
4873                let fd = unsafe {
4874                    crate::proc_view::mount_private_proc(!private_proc.mount_namespace_entered)
4875                }
4876                .map_err(|(step, errno)| {
4877                    io::Error::other(crate::proc_view::describe_mount_failure(step, errno))
4878                })?;
4879                // SAFETY: `fd` was just returned by a successful `open` and nothing else owns it.
4880                Some(unsafe { <OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd) })
4881            }
4882        };
4883
4884        // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
4885        // composed root. This has to precede every *remaining* step in this closure, because
4886        // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
4887        // the filter below covers, permanently and deliberately.
4888        // The composed root is therefore built by this process while it still has its pre-filter
4889        // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
4890        // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
4891        // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
4892        // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
4893        // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
4894        if tier == EnforcementTier::KernelSealed {
4895            if let Some(spec) = sealed_spec {
4896                #[cfg(test)]
4897                if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4898                    return Err(io::Error::other(format!(
4899                        "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4900                        crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4901                    )));
4902                }
4903                crate::sealed::construct_composed_root(spec)
4904                    .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4905            }
4906        }
4907
4908        // Before anything else in this window, on every kernel tier: shut the fd-inheritance
4909        // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
4910        // ruleset installed further down this function — it can do nothing about a descriptor
4911        // the child already holds open at that point, because that fd was opened before the
4912        // ruleset existed and there is no operation left for the ruleset to intercept. Until
4913        // this call existed, the only thing stopping a spawned shell from inheriting an
4914        // arbitrary open fd was the empirical accident that nothing else happened to be open in
4915        // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
4916        // config read, a lock file, a log handle — would have leaked silently into every
4917        // subsequent subprocess, on every platform and every tier, with no test to catch it.
4918        //
4919        // Running it here is also what makes it cover this function's *own* descriptors: the
4920        // diagnostic pipe and the Landlock grant fds are used below but must never survive the
4921        // `execve`, and neither may the session's namespace handles, already used above.
4922        match mark_inherited_fds_cloexec() {
4923            Err(error)
4924                if fd_hygiene_fallback
4925                    && matches!(error.raw_os_error(), Some(libc::ENOSYS | libc::EINVAL)) =>
4926            {
4927                mark_inherited_fds_cloexec_one_by_one()?;
4928            }
4929            result => result?,
4930        }
4931
4932        // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
4933        // capability model are independent, both-must-allow gates, so narrowing the Landlock
4934        // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
4935        // must also run *before* `install_seccomp_filter`, because it is what sets
4936        // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
4937        // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
4938        // failure is its own distinct, fail-closed error path before any filter is installed.
4939        drop_all_capabilities()?;
4940
4941        // The child stays *non-dumpable*, inherited from the runtime process
4942        // (`security::harden_process_dumpable`). The one reader of its memory, the bind observer's
4943        // `process_vm_readv` of a `bind` `sockaddr`, does not need it dumpable: the runtime's euid
4944        // owns the session's user namespace and so holds `CAP_SYS_PTRACE` in it and in every user
4945        // namespace nested below it. Marking the child dumpable opens same-uid `ptrace`, `environ`
4946        // and core-dump access for the whole life of every shell subprocess: do not do so without
4947        // a reader that requires it.
4948        //
4949        // On an observed spawn the filter routes `bind` to a user notification, and its listener
4950        // goes to the parent over `bind_listener` and is closed here, before `execve`.
4951        install_seccomp_filter(unix_sockets_allowed, bind_listener)?;
4952
4953        if matches!(
4954            tier,
4955            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4956        ) {
4957            if let Some(fds) = landlock_fds {
4958                apply_landlock_scope(fds, proc_fd.as_ref()).map_err(io::Error::other)?;
4959            }
4960        }
4961
4962        Ok(())
4963    }
4964
4965    /// The session's namespace descriptors a command enters, all held open by the join handle
4966    /// the `pre_exec` closure owns.
4967    #[derive(Debug, Clone, Copy)]
4968    pub(super) struct SessionNamespaces {
4969        pub(super) user_ns_fd: RawFd,
4970        pub(super) net_ns_fd: RawFd,
4971        pub(super) pid_ns_fd: RawFd,
4972    }
4973
4974    /// A spawn that mounts the session's private `/proc`.
4975    #[derive(Debug, Clone, Copy)]
4976    pub(super) struct PrivateProc {
4977        /// Whether the `/tmp` mapping already entered a private mount namespace.
4978        pub(super) mount_namespace_entered: bool,
4979    }
4980
4981    /// The exit status a command forked into the session's PID namespace leaves when it finds
4982    /// its relay already gone, before running anything.
4983    const RELAY_GONE_EXIT: i32 = 125;
4984
4985    /// Enters the session's PID namespace for the command: `setns(pid_ns_fd, CLONE_NEWPID)`, then
4986    /// `fork()`. Returns only in the forked child — the command, inside the namespace — and only
4987    /// once it has made its relay's death its own (`PR_SET_PDEATHSIG`) and seen the relay alive.
4988    /// The calling process becomes the relay ([`run_relay`]) and never returns.
4989    ///
4990    /// The command stays in the relay's process group and cgroup: both are inherited across the
4991    /// fork. A live relay is outside the namespace, so `getppid()` reads `0` in the command; any
4992    /// other value is the namespace's holder, which inherited the command because the relay
4993    /// already died, and the command exits before running anything.
4994    ///
4995    /// # Safety
4996    /// Post-`fork()`, pre-exec child context only, after `join_session_netns`.
4997    unsafe fn enter_session_pid_namespace(pid_ns_fd: RawFd) -> io::Result<()> {
4998        // SAFETY: the caller's contract; every call takes integers or live stack locals, and the
4999        // relay branch never returns.
5000        unsafe {
5001            if libc::setns(pid_ns_fd, libc::CLONE_NEWPID) != 0 {
5002                return Err(io::Error::other(format!(
5003                    "sandbox: joining this session's PID namespace failed: {}",
5004                    io::Error::last_os_error()
5005                )));
5006            }
5007            // The relay `waitpid`s its command, which an ignored `SIGCHLD` would turn into
5008            // `ECHILD` once the kernel had reaped it.
5009            let mut default: libc::sigaction = std::mem::zeroed();
5010            default.sa_sigaction = libc::SIG_DFL;
5011            libc::sigaction(libc::SIGCHLD, &default, std::ptr::null_mut());
5012            let command = libc::fork();
5013            if command < 0 {
5014                return Err(io::Error::other(format!(
5015                    "sandbox: forking the command into this session's PID namespace failed: {}",
5016                    io::Error::last_os_error()
5017                )));
5018            }
5019            if command > 0 {
5020                run_relay(command);
5021            }
5022            if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
5023                return Err(io::Error::other(format!(
5024                    "sandbox: prctl(PR_SET_PDEATHSIG) in the command failed: {}",
5025                    io::Error::last_os_error()
5026                )));
5027            }
5028            if libc::getppid() != 0 {
5029                libc::_exit(RELAY_GONE_EXIT);
5030            }
5031        }
5032        Ok(())
5033    }
5034
5035    /// The relay's whole life: the process `std::process::Child` refers to, outside the session's
5036    /// PID namespace, which runs no capsule code and holds no descriptor.
5037    ///
5038    /// It blocks every signal (a process-group signal reaches the command directly, and `SIGKILL`
5039    /// still ends the relay, which `PR_SET_PDEATHSIG` turns into the command's end), closes every
5040    /// descriptor — `std`'s exec-status pipe, the diagnostic pipe, stdio and the holder's
5041    /// lifeline among them, so none stays open on the relay's account, even where `close_range`
5042    /// is refused (`cgroup::close_range`) — and waits for the
5043    /// command. It then leaves the command's status as its own: the exit code, or the
5044    /// terminating signal re-raised with its default disposition, falling back to `128 + sig`.
5045    /// `wait4` on the relay reports the command's resource use with its own, since Linux counts a
5046    /// reaped child's waited-for children.
5047    ///
5048    /// # Safety
5049    /// Post-`fork()` child context only; every call is async-signal-safe and nothing allocates.
5050    unsafe fn run_relay(command: libc::pid_t) -> ! {
5051        // SAFETY: the caller's contract; every call takes integers or live stack locals.
5052        unsafe {
5053            let mut all: libc::sigset_t = std::mem::zeroed();
5054            libc::sigfillset(&mut all);
5055            libc::sigprocmask(libc::SIG_SETMASK, &all, std::ptr::null_mut());
5056            crate::cgroup::close_range(0, libc::c_uint::MAX);
5057            let mut status: libc::c_int = 0;
5058            loop {
5059                let waited = libc::waitpid(command, &mut status, 0);
5060                if waited == command {
5061                    break;
5062                }
5063                if waited < 0 && *libc::__errno_location() == libc::EINTR {
5064                    continue;
5065                }
5066                libc::_exit(1);
5067            }
5068            if libc::WIFEXITED(status) {
5069                libc::_exit(libc::WEXITSTATUS(status));
5070            }
5071            if libc::WIFSIGNALED(status) {
5072                let signal = libc::WTERMSIG(status);
5073                let mut default: libc::sigaction = std::mem::zeroed();
5074                default.sa_sigaction = libc::SIG_DFL;
5075                libc::sigaction(signal, &default, std::ptr::null_mut());
5076                let mut only: libc::sigset_t = std::mem::zeroed();
5077                libc::sigemptyset(&mut only);
5078                libc::sigaddset(&mut only, signal);
5079                libc::sigprocmask(libc::SIG_UNBLOCK, &only, std::ptr::null_mut());
5080                libc::kill(libc::getpid(), signal);
5081                libc::_exit(128 + signal);
5082            }
5083            libc::_exit(1)
5084        }
5085    }
5086
5087    /// [`mark_inherited_fds_cloexec`] without `close_range`: `fcntl(F_SETFD, FD_CLOEXEC)` on every
5088    /// open descriptor from [`super::FD_HYGIENE_FIRST_FD`] below the soft `RLIMIT_NOFILE`, the
5089    /// highest number a descriptor can have. The same end state, one syscall pair per number.
5090    /// Only a guest worker's spawn takes it ([`super::ShellEnforcement::fd_hygiene_fallback`]).
5091    pub(super) fn mark_inherited_fds_cloexec_one_by_one() -> io::Result<()> {
5092        // SAFETY: `getrlimit` writes into a stack local; `fcntl` takes integers. All are
5093        // async-signal-safe, as this post-fork, pre-exec window requires.
5094        unsafe {
5095            let mut limit: libc::rlimit = std::mem::zeroed();
5096            if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
5097                return Err(io::Error::last_os_error());
5098            }
5099            let end = libc::c_int::try_from(limit.rlim_cur).unwrap_or(libc::c_int::MAX);
5100            let mut fd = super::FD_HYGIENE_FIRST_FD as libc::c_int;
5101            while fd < end {
5102                let flags = libc::fcntl(fd, libc::F_GETFD);
5103                if flags >= 0
5104                    && flags & libc::FD_CLOEXEC == 0
5105                    && libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) != 0
5106                {
5107                    return Err(io::Error::last_os_error());
5108                }
5109                fd += 1;
5110            }
5111        }
5112        Ok(())
5113    }
5114
5115    /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
5116    /// the only descriptors that survive into the exec'd program are the ones the runtime
5117    /// deliberately set up (stdio) rather than whatever happened to be open.
5118    ///
5119    /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
5120    /// the required semantics here, not a softer variant of it: several fds in this range are
5121    /// still needed *inside* this `pre_exec` window — the diagnostic pipe a failure further down
5122    /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules from. Closing
5123    /// them here would break the very enforcement setup that follows; flagging them means they
5124    /// keep working until `execve`, which then closes them for us.
5125    ///
5126    /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
5127    /// stdio decision and its reasoning.
5128    ///
5129    /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
5130    /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
5131    /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
5132    /// so every spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
5133    /// fails rather than silently running without fd hygiene. That is deliberate and it is
5134    /// this module's established fail-closed discipline (`drop_all_capabilities`,
5135    /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
5136    /// error rather than degrade), but it is a real, user-visible narrowing of the supported
5137    /// kernel range for kernel-enforcement tiers. A guest worker's spawn falls back to
5138    /// [`mark_inherited_fds_cloexec_one_by_one`] there instead.
5139    pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
5140        // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
5141        // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
5142        // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
5143        // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
5144        // spelling from `close_range(2)`.
5145        let rc = unsafe {
5146            libc::syscall(
5147                libc::SYS_close_range,
5148                super::FD_HYGIENE_FIRST_FD,
5149                libc::c_uint::MAX,
5150                libc::CLOSE_RANGE_CLOEXEC,
5151            )
5152        };
5153        if rc != 0 {
5154            return Err(io::Error::last_os_error());
5155        }
5156        Ok(())
5157    }
5158
5159    /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
5160    /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
5161    /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
5162    /// only capabilities 0–31 and make the kernel log a deprecation warning.
5163    const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
5164
5165    /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
5166    /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
5167    /// capabilities — so this can never under-cover. Numbers the running kernel does not define
5168    /// return `EINVAL`, which the loop skips.
5169    const CAP_LAST_CAP_FALLBACK: u32 = 63;
5170
5171    /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
5172    /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
5173    /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
5174    /// `msghdr`/`cmsghdr` handling.
5175    ///
5176    /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
5177    /// handed to `capset(2)`; Rust itself only ever writes them.
5178    #[repr(C)]
5179    #[allow(dead_code)]
5180    struct CapUserHeader {
5181        version: u32,
5182        pid: libc::c_int,
5183    }
5184
5185    /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
5186    /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
5187    /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
5188    /// note.
5189    #[repr(C)]
5190    #[derive(Clone, Copy)]
5191    #[allow(dead_code)]
5192    struct CapUserData {
5193        effective: u32,
5194        permitted: u32,
5195        inheritable: u32,
5196    }
5197
5198    /// Strips every Linux capability from the forked child before `execve()`, in three steps
5199    /// whose **order matters**:
5200    ///
5201    ///   1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
5202    ///      still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
5203    ///      `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
5204    ///   2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
5205    ///      Shrinking your own permitted set to any subset — including empty — never requires
5206    ///      `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
5207    ///      inheritable also empties the *ambient* set, which the kernel maintains as a subset of
5208    ///      both — that is what covers a non-root capsule handed an unexpected ambient
5209    ///      `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
5210    ///   3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
5211    ///      file-capability binary, and so the seccomp filter installed next can load without
5212    ///      `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
5213    ///      longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
5214    ///      as a side effect.
5215    ///
5216    /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
5217    /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
5218    /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
5219    /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
5220    /// genuinely unprivileged caller, the bounding set would silently survive while this function
5221    /// still reported success.
5222    ///
5223    /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
5224    /// capability transition treats a file's permitted set as all-ones when the process's real
5225    /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
5226    /// would be undone by the very next `execve`; emptying the bounding set is what makes the
5227    /// post-exec permitted (and hence effective) set empty, which is what actually takes
5228    /// `CAP_MKNOD` away from the shell.
5229    ///
5230    /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
5231    /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
5232    /// — and strictly narrows what the subprocess can reach.
5233    fn drop_all_capabilities() -> io::Result<()> {
5234        drop_capability_bounding_set()?;
5235        clear_capability_sets()?;
5236        set_no_new_privs()
5237    }
5238
5239    /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
5240    ///
5241    ///   - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
5242    ///   - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
5243    ///     process that never held the capability being dropped either. Hard-failing here would
5244    ///     break every non-root `mur run`.
5245    ///
5246    /// Any other errno is unexpected and propagates, aborting the exec — this module's
5247    /// fail-closed invariant: no silently-unenforced spawn.
5248    #[allow(unsafe_code)]
5249    fn drop_capability_bounding_set() -> io::Result<()> {
5250        let zero: libc::c_ulong = 0;
5251
5252        for cap in 0..=kernel_last_cap() {
5253            let cap_arg = libc::c_ulong::from(cap);
5254            // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
5255            // ignored arguments — no pointers and no borrowed memory are involved.
5256            let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
5257            if rc == 0 {
5258                continue;
5259            }
5260
5261            let error = io::Error::last_os_error();
5262            match error.raw_os_error() {
5263                Some(libc::EINVAL) | Some(libc::EPERM) => continue,
5264                _ => return Err(error),
5265            }
5266        }
5267
5268        Ok(())
5269    }
5270
5271    /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
5272    /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
5273    /// everything. Any error is therefore unexpected and fails closed.
5274    #[allow(unsafe_code)]
5275    fn clear_capability_sets() -> io::Result<()> {
5276        let header = CapUserHeader {
5277            version: LINUX_CAPABILITY_VERSION_3,
5278            // 0 means "the calling thread" — the only pid `capset` accepts for a capability
5279            // change since Linux 2.6.25.
5280            pid: 0,
5281        };
5282        let data = [CapUserData {
5283            effective: 0,
5284            permitted: 0,
5285            inheritable: 0,
5286        }; 2];
5287
5288        // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
5289        // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
5290        // expect. The kernel only reads through them.
5291        let rc = unsafe {
5292            libc::syscall(
5293                libc::SYS_capset,
5294                &header as *const CapUserHeader,
5295                data.as_ptr(),
5296            )
5297        };
5298        if rc != 0 {
5299            return Err(io::Error::last_os_error());
5300        }
5301
5302        Ok(())
5303    }
5304
5305    /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
5306    /// process's privilege and is never refused for a well-formed call, so any error is
5307    /// unexpected and fails closed. Same call convention and fail-closed style as
5308    /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
5309    /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
5310    /// whole-process hardening.
5311    #[allow(unsafe_code)]
5312    fn set_no_new_privs() -> io::Result<()> {
5313        #[cfg(test)]
5314        if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
5315            return Err(io::Error::other(
5316                "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
5317            ));
5318        }
5319        // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
5320        // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
5321        // upper 32 bits would be unspecified in a variadic call on aarch64.
5322        let enable: libc::c_ulong = 1;
5323        let zero: libc::c_ulong = 0;
5324
5325        // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
5326        // and no borrowed memory are involved.
5327        let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
5328        if rc != 0 {
5329            return Err(io::Error::last_os_error());
5330        }
5331
5332        Ok(())
5333    }
5334
5335    /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
5336    /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
5337    /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
5338    /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
5339    /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
5340    #[allow(unsafe_code)]
5341    fn kernel_last_cap() -> u32 {
5342        const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
5343
5344        // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
5345        // only reads through the pointer.
5346        let fd = unsafe {
5347            libc::open(
5348                PATH.as_ptr() as *const libc::c_char,
5349                libc::O_RDONLY | libc::O_CLOEXEC,
5350            )
5351        };
5352        if fd < 0 {
5353            return CAP_LAST_CAP_FALLBACK;
5354        }
5355
5356        let mut buf = [0u8; 16];
5357        // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
5358        // is passed, so `read` cannot write past it.
5359        let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
5360        // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
5361        unsafe { libc::close(fd) };
5362
5363        if read <= 0 {
5364            return CAP_LAST_CAP_FALLBACK;
5365        }
5366
5367        let parsed = std::str::from_utf8(&buf[..read as usize])
5368            .ok()
5369            .and_then(|text| text.trim().parse::<u32>().ok());
5370
5371        match parsed {
5372            Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
5373            _ => CAP_LAST_CAP_FALLBACK,
5374        }
5375    }
5376
5377    /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
5378    /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
5379    /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
5380    /// does support Landlock, so this is a real combination, not a hypothetical one).
5381    ///
5382    /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
5383    /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
5384    /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
5385    const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
5386        ("landlock_create_ruleset", 444),
5387        ("landlock_add_rule", 445),
5388        ("landlock_restrict_self", 446),
5389    ];
5390
5391    /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
5392    /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
5393    /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
5394    /// held against and the headroom between them are recorded in
5395    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
5396    ///
5397    /// **No rule of this filter decides through a notification.** Network enforcement lives in
5398    /// [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
5399    /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
5400    /// deciding `Notify` rule may come back. With `observe_binds`, `bind` is routed to a user
5401    /// notification the session's bind observer always answers with continue, in place of its
5402    /// `Allow`; without it the filter carries no `Notify` rule at all.
5403    ///
5404    /// One mechanism plus a default:
5405    ///
5406    ///   - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
5407    ///     a plain integer argument, so the comparison compiles straight into the loaded BPF
5408    ///     program and is evaluated in-kernel at syscall time: no notification is raised and no
5409    ///     memory of another task is read. That is what makes it structurally immune to the
5410    ///     pointer-read TOCTOU class of problem in the first place.
5411    ///   - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
5412    ///     neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
5413    ///     argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
5414    ///     `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
5415    ///     unreachable — they are simply absent, not argument-matched.
5416    fn build_seccomp_filter(
5417        unix_sockets_allowed: bool,
5418        observe_binds: bool,
5419    ) -> io::Result<libseccomp::ScmpFilterContext> {
5420        // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
5421        // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
5422        // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
5423        // call's arguments and refused it", `EPERM` means "this syscall is not part of the
5424        // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
5425        // unmediated-surface problem from a policy decision.
5426        let mut filter =
5427            libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5428                .map_err(to_io_err)?;
5429
5430        // Ask the kernel to record every action this filter takes that is not `Allow` in the
5431        // audit trail, so a denial is attributable after the fact to a syscall number, pid and
5432        // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
5433        // has no channel for anything richer — so this is the only way an operator debugging a
5434        // workload that dies on an unexpected denial gets a syscall name to look at.
5435        //
5436        // Best-effort on purpose, and the one place in this function where an error is swallowed:
5437        // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
5438        // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
5439        // not an enforcement one — the default-deny action and every rule below are unaffected —
5440        // and turning it into a hard failure would take a host with an old libseccomp from
5441        // "denials are logged less legibly" to "no capsule can spawn a shell at all".
5442        //
5443        // Setting the attribute is necessary but not sufficient: the kernel only logs an action
5444        // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
5445        // configuration this process does not control.
5446        // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
5447        // these denials are verified by.
5448        let _ = filter.set_ctl_log(true);
5449
5450        // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
5451        // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
5452        // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
5453        // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
5454        // sees one consistent errno across both argument-level mechanisms.
5455        //
5456        // Untouched by the network-namespace work, and deliberately so: a network namespace does
5457        // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
5458        // still the only thing standing between a capsule and `/var/run/docker.sock`.
5459        //
5460        // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
5461        // adapt the rule to the architectures in the filter — on an arch where `socket` is
5462        // reached through `socketcall(2)` rather than as its own syscall, the exact form would
5463        // fail the whole install instead. Fail-closed is preserved either way, since any error
5464        // here propagates out of `pre_exec` and aborts the spawn.
5465        //
5466        // One rule per domain rather than one rule with three comparators: comparators within a
5467        // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
5468        // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
5469        let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
5470        for domain in super::denied_socket_domains(unix_sockets_allowed) {
5471            filter
5472                .add_rule_conditional(
5473                    libseccomp::ScmpAction::Errno(libc::EACCES),
5474                    socket_syscall,
5475                    &[libseccomp::ScmpArgCompare::new(
5476                        0,
5477                        libseccomp::ScmpCompareOp::Equal,
5478                        domain as u64,
5479                    )],
5480                )
5481                .map_err(to_io_err)?;
5482        }
5483
5484        // The permitted domains need positive rules of their own now that the filter's default is
5485        // a deny — without them `socket()` would be refused for every family, IP included.
5486        //
5487        // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
5488        // libseccomp resolves an unconditional rule for a syscall by *discarding* every
5489        // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
5490        // but the new filter has no chains so the existing chains must be cleared"), so a broad
5491        // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
5492        // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
5493        // default profile carries the same warning against combining a broad `socket` allow with
5494        // explicit errno rules, and works around it the same way — with argument-matched rules
5495        // only. The two domain sets are disjoint by construction (a test pins that), so no
5496        // `socket()` call can match both an allow and a deny rule.
5497        for domain in super::allowed_socket_domains(unix_sockets_allowed) {
5498            filter
5499                .add_rule_conditional(
5500                    libseccomp::ScmpAction::Allow,
5501                    socket_syscall,
5502                    &[libseccomp::ScmpArgCompare::new(
5503                        0,
5504                        libseccomp::ScmpCompareOp::Equal,
5505                        domain as u64,
5506                    )],
5507                )
5508                .map_err(to_io_err)?;
5509        }
5510
5511        // The allowlist proper. Two failure modes are both handled by skipping the name:
5512        //
5513        //   - `from_name` fails when this host's libseccomp does not know the syscall (a name
5514        //     newer than the installed library);
5515        //   - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
5516        //     not exist on this architecture — every legacy x86_64 spelling in the array
5517        //     (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
5518        //
5519        // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
5520        // so the filter can only ever come out *narrower* than intended, never wider. Propagating
5521        // instead would abort every single spawn on the first arch-specific name, which is why
5522        // this is the deliberate exception to the module's otherwise-strict error handling.
5523        //
5524        // `bind` is skipped when observed: two unconditional rules with different actions for one
5525        // syscall do not coexist in libseccomp, so its `Notify` rule below replaces the `Allow`.
5526        for name in super::SECCOMP_SYSCALL_ALLOWLIST {
5527            if observe_binds && *name == "bind" {
5528                continue;
5529            }
5530            if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5531                let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
5532            }
5533        }
5534
5535        // The observed `bind`: one unconditional `Notify`, never an argument-conditional one,
5536        // because libseccomp cannot express "notify on some lengths, allow the rest" without
5537        // overlapping rules for one syscall. Fail-closed like the Landlock rules below: an
5538        // observed spawn whose filter cannot carry it does not run.
5539        if observe_binds {
5540            let bind = libseccomp::ScmpSyscall::from_name("bind").map_err(to_io_err)?;
5541            filter
5542                .add_rule(libseccomp::ScmpAction::Notify, bind)
5543                .map_err(to_io_err)?;
5544        }
5545
5546        // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
5547        // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
5548        // deny would refuse the child's own filesystem scoping and abort every spawn on that
5549        // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
5550        // that installs it, never widen it, and Docker's default profile allows them too.
5551        //
5552        // Resolved by name where possible, with the syscall number as a fallback, because these
5553        // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
5554        // policy above would silently turn an older libseccomp into "no capsule can run a shell
5555        // on a Landlock-capable host". The numbers are the same on every architecture Linux has
5556        // added Landlock to (they were assigned after the syscall-number unification), so the
5557        // fallback is not architecture-specific.
5558        for (name, fallback_nr) in LANDLOCK_SYSCALLS {
5559            let syscall = libseccomp::ScmpSyscall::from_name(name)
5560                .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
5561            filter
5562                .add_rule(libseccomp::ScmpAction::Allow, syscall)
5563                .map_err(to_io_err)?;
5564        }
5565
5566        Ok(filter)
5567    }
5568
5569    /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
5570    /// the filter stays in force for the rest of this task's life and is inherited across the
5571    /// `execve` that follows, which is why nothing outside a forked child ever calls this.
5572    ///
5573    /// With `bind_listener`, the filter is the observed one, and the listener `load()` creates
5574    /// (`SECCOMP_FILTER_FLAG_NEW_LISTENER`, which libseccomp sets for a filter with a `Notify`
5575    /// rule) is sent to the parent and then closed here, so the exec'd program never holds it.
5576    /// A listener that cannot be sent fails the spawn: without its reader every `bind` in the
5577    /// command would fail with `ENOSYS`.
5578    fn install_seccomp_filter(
5579        unix_sockets_allowed: bool,
5580        bind_listener: Option<&BindListenerHandoff>,
5581    ) -> io::Result<()> {
5582        // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
5583        // on Drop) only frees the userspace filter-building context, while the filter itself is
5584        // already installed in the kernel and is inherited across the `execve` that follows.
5585        let filter = build_seccomp_filter(unix_sockets_allowed, bind_listener.is_some())?;
5586        filter.load().map_err(to_io_err)?;
5587        let Some(handoff) = bind_listener else {
5588            return Ok(());
5589        };
5590        let listener = filter.get_notify_fd().map_err(|error| {
5591            io::Error::other(format!(
5592                "sandbox: the seccomp filter observing bind left no listener: {error}"
5593            ))
5594        })?;
5595        let sent = handoff.send(listener);
5596        // SAFETY: `listener` is the descriptor `load()` created for this process and nothing else
5597        // in it owns; `close` takes one integer.
5598        unsafe { libc::close(listener) };
5599        sent.map_err(|error| {
5600            io::Error::other(format!(
5601                "sandbox: sending the seccomp listener observing bind failed: {error}"
5602            ))
5603        })
5604    }
5605
5606    /// The child's end of an observed spawn's socketpair, which its seccomp listener is sent
5607    /// over.
5608    pub(super) struct BindListenerHandoff {
5609        socket: OwnedFd,
5610    }
5611
5612    impl BindListenerHandoff {
5613        /// Sends `listener` in one `SCM_RIGHTS` message. Runs in the `pre_exec` window, so it goes
5614        /// through [`crate::network_namespace::send_fds`], which uses stack buffers only.
5615        fn send(&self, listener: RawFd) -> io::Result<()> {
5616            use std::os::fd::AsRawFd;
5617            // SAFETY: called only from the forked child's `pre_exec` window, which is the context
5618            // `send_fds` requires; the socket is the end this struct owns.
5619            unsafe { crate::network_namespace::send_fds(self.socket.as_raw_fd(), &[listener]) }
5620                .map_err(|()| io::Error::last_os_error())
5621        }
5622    }
5623
5624    /// A `SOCK_SEQPACKET | SOCK_CLOEXEC` socketpair for one observed spawn: the parent's end, and
5625    /// the child's.
5626    pub(super) fn bind_listener_socketpair() -> Result<(OwnedFd, BindListenerHandoff), String> {
5627        use std::os::fd::FromRawFd;
5628        let mut fds = [0 as RawFd; 2];
5629        // SAFETY: `fds` is a 2-element array, exactly what `socketpair` writes into.
5630        let rc = unsafe {
5631            libc::socketpair(
5632                libc::AF_UNIX,
5633                libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
5634                0,
5635                fds.as_mut_ptr(),
5636            )
5637        };
5638        if rc != 0 {
5639            return Err(format!(
5640                "sandbox: socketpair() for the bind listener failed: {}",
5641                io::Error::last_os_error()
5642            ));
5643        }
5644        // SAFETY: both descriptors were just returned by `socketpair` and nothing else owns them.
5645        let (parent, child) =
5646            unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
5647        Ok((parent, BindListenerHandoff { socket: child }))
5648    }
5649
5650    /// The parent's receive of the listener [`BindListenerHandoff::send`] sent, without waiting
5651    /// (`MSG_DONTWAIT`) and close-on-exec (`MSG_CMSG_CLOEXEC`).
5652    pub(super) fn receive_bind_listener(socket: &OwnedFd) -> io::Result<OwnedFd> {
5653        use std::os::fd::{AsRawFd, FromRawFd};
5654        let mut payload = [0u8; 1];
5655        let mut iov = libc::iovec {
5656            iov_base: payload.as_mut_ptr().cast(),
5657            iov_len: payload.len(),
5658        };
5659        /// Room for `CMSG_SPACE(sizeof(int))`, aligned for `struct cmsghdr`.
5660        #[repr(C, align(8))]
5661        struct Control([u8; 64]);
5662        let mut cmsg = Control([0; 64]);
5663        // SAFETY: `msghdr` is a plain C struct for which all-zero is valid; every pointer set in
5664        // it names a local that outlives the `recvmsg` call, and `CMSG_FIRSTHDR`/`CMSG_DATA` read
5665        // only inside `cmsg`, whose length the kernel wrote back into `msg_controllen`.
5666        unsafe {
5667            let mut message: libc::msghdr = std::mem::zeroed();
5668            message.msg_iov = &mut iov;
5669            message.msg_iovlen = 1;
5670            message.msg_control = cmsg.0.as_mut_ptr().cast();
5671            message.msg_controllen = cmsg.0.len() as _;
5672            let received = libc::recvmsg(
5673                socket.as_raw_fd(),
5674                &mut message,
5675                libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
5676            );
5677            if received < 0 {
5678                return Err(io::Error::last_os_error());
5679            }
5680            let header = libc::CMSG_FIRSTHDR(&message);
5681            if header.is_null()
5682                || (*header).cmsg_level != libc::SOL_SOCKET
5683                || (*header).cmsg_type != libc::SCM_RIGHTS
5684            {
5685                return Err(io::Error::other(
5686                    "the message carried no SCM_RIGHTS descriptor",
5687                ));
5688            }
5689            let fd = std::ptr::read_unaligned(libc::CMSG_DATA(header).cast::<RawFd>());
5690            Ok(OwnedFd::from_raw_fd(fd))
5691        }
5692    }
5693
5694    fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
5695        io::Error::other(error.to_string())
5696    }
5697
5698    /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
5699    /// instruction. Dividing an export's length by it is what turns bytes into an instruction
5700    /// count.
5701    #[cfg(test)]
5702    const SOCK_FILTER_BYTES: usize = 8;
5703
5704    /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
5705    /// returns what it wrote.
5706    ///
5707    /// Both exporters take a descriptor and nothing else, so the buffer has to come from
5708    /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
5709    /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
5710    /// crate and so is unavailable to anything outside a test build.
5711    #[cfg(test)]
5712    fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
5713    where
5714        F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
5715    {
5716        use std::io::{Read, Seek, SeekFrom};
5717        use std::os::fd::FromRawFd;
5718
5719        // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
5720        // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
5721        // the descriptor lives and dies inside this function.
5722        let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
5723        if raw < 0 {
5724            return Err(io::Error::last_os_error());
5725        }
5726        // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
5727        // is exactly the precondition for handing it to `OwnedFd`.
5728        let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
5729
5730        export(&memfd).map_err(to_io_err)?;
5731
5732        let mut file = std::fs::File::from(memfd);
5733        file.seek(SeekFrom::Start(0))?;
5734        let mut exported = Vec::new();
5735        file.read_to_end(&mut exported)?;
5736        Ok(exported)
5737    }
5738
5739    /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
5740    /// this host, for the given `unix_sockets_allowed`.
5741    ///
5742    /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
5743    /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
5744    /// divided by that record size. The number depends on the host — how many
5745    /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
5746    /// which is why it is measured here rather than written down.
5747    ///
5748    /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
5749    /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
5750    #[cfg(test)]
5751    fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
5752        let filter = build_seccomp_filter(unix_sockets_allowed, false)?;
5753        let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5754        Ok(exported.len() / SOCK_FILTER_BYTES)
5755    }
5756
5757    /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
5758    /// one block per syscall carrying a rule, each block naming the argument comparisons and the
5759    /// action. It is the only view of a built context that shows the rule *shape* rather than the
5760    /// compiled program's size, which is what a test pinning the rule sequence needs.
5761    ///
5762    /// `observe_binds` builds the observed filter, whose `bind` notifies instead of allowing.
5763    #[cfg(test)]
5764    fn seccomp_filter_pfc(unix_sockets_allowed: bool, observe_binds: bool) -> io::Result<String> {
5765        let filter = build_seccomp_filter(unix_sockets_allowed, observe_binds)?;
5766        let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
5767        String::from_utf8(exported).map_err(to_io_err)
5768    }
5769
5770    /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
5771    ///
5772    ///   - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
5773    ///     FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
5774    ///     `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
5775    ///     [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
5776    ///     is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
5777    ///     the path it resolved itself, so nothing the capsule writes into its workdir can run;
5778    ///   - the session's temporary folder gets that **same** right-set from the same local
5779    ///     binding, whenever the enforcement holds one. The composed root and the mount-namespace
5780    ///     mapping both bind it at `/tmp` read-write, but Landlock matches an access to `/tmp/x`
5781    ///     against the bind's own root inode rather than the workdir's, so without this rule the
5782    ///     mount is writable and every access to it is nevertheless `EACCES` — bash heredocs,
5783    ///     `mktemp`, a compiler's intermediate objects and a package manager's build scratch all
5784    ///     fail. Identical rights are the honest ones: it is counted by the same
5785    ///     `workdir_max_bytes` guard, so it must be neither more nor less runnable than the
5786    ///     workdir itself;
5787    ///   - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
5788    ///     shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
5789    ///     `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
5790    ///     [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
5791    ///     grant — never write — with two bits added on top.
5792    ///     Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
5793    ///     exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
5794    ///     file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
5795    ///     whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
5796    ///     — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
5797    ///     (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
5798    ///     `true` everywhere except the two tier-issued sealed grants, which are deliberately
5799    ///     readable (and, on a directory, enumerable) but not runnable;
5800    ///   - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
5801    ///     and `Truncate` if its `writable` bit is set. That bit is set for exactly one path —
5802    ///     `/dev/null` — which is therefore the *only* writable path outside the workdir in the
5803    ///     whole sandbox. This list is a compile-time constant, not manifest-derived: no capability
5804    ///     declaration widens or narrows it. Every device path it does not name stays denied by the
5805    ///     ordinary no-matching-rule path below.
5806    ///
5807    /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
5808    /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
5809    /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
5810    /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
5811    /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
5812    /// so a write to any other outside path — including one of these read+execute grant paths — is
5813    /// still denied.
5814    ///
5815    /// The ruleset is built at `fds.abi` — the kernel's ABI capped at
5816    /// [`super::LANDLOCK_TARGET_ABI`], decided in the parent — or at ABI v1 when that is `None`.
5817    /// `handle_access` declares the **full** `from_all` set of that ABI, under `CompatLevel::HardRequirement`, and every rule's
5818    /// rights are masked to it first: a right the ABI does not know would otherwise fail
5819    /// `add_rule`, and masking it out is what a kernel of that ABI would enforce anyway. Built at
5820    /// v1, the ruleset is bit for bit the one a 5.13–5.18 kernel gets.
5821    ///
5822    /// `handle_access` must declare the union of every access bit any rule uses, and it is the
5823    /// full set deliberately: a bit declared there but granted by no rule is denied for
5824    /// the whole domain, which is exactly how the rights missing from
5825    /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
5826    /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
5827    /// path, on a host where one of the three is missing — that
5828    /// fails to *open* is skipped
5829    /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
5830    /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
5831    /// preserving the module's fail-closed invariant.
5832    ///
5833    /// `proc_fd` is the root of the session's private `/proc`, mounted and opened in this child by
5834    /// `crate::proc_view::mount_private_proc`: `ReadFile | ReadDir` beneath it, never `Execute`
5835    /// and never a write right. `None` when the view is the host's, which then keeps no rule and
5836    /// every open beneath `/proc` is refused.
5837    fn apply_landlock_scope(
5838        fds: &LandlockChildFds,
5839        proc_fd: Option<&OwnedFd>,
5840    ) -> Result<(), String> {
5841        #[cfg(test)]
5842        if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
5843            return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
5844        }
5845
5846        use landlock::{
5847            Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
5848            RulesetCreatedAttr, ABI,
5849        };
5850
5851        let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
5852        let handled = AccessFs::from_all(abi);
5853        // Enough for the loader to open, read, and map-execute a binary or shared library, and to
5854        // open a file inside a granted directory by exact name — but no write access, and (without
5855        // `ReadDir`) no enumeration of the directory's own entries.
5856        let read_execute = AccessFs::Execute | AccessFs::ReadFile;
5857        // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
5858        let read_execute_list = read_execute | AccessFs::ReadDir;
5859        // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
5860        // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
5861        // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
5862        let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
5863        // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
5864        // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
5865        // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
5866        let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5867        // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
5868        // not a directory) — only the data bits, with `WriteFile` reserved for the writable
5869        // devices. `Truncate` rides with `WriteFile` because a shell's `>/dev/null` opens with
5870        // `O_TRUNC`, and handling `Truncate` from ABI v3 would otherwise refuse it.
5871        let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5872        let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
5873
5874        // The one writable right-set in this function, resolved once and used by both rules that
5875        // carry it. The session's temporary folder is counted by the workdir's size guard and is
5876        // the capsule's other scratch space, so it gets the same rights, `Execute` axis included:
5877        // `workdir_exec: false` must no more permit running a binary from `/tmp` than from the
5878        // workdir. Sharing the binding is what keeps that true; two separate calls could drift
5879        // apart.
5880        let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
5881
5882        // Every fd here was already opened in the parent (before fork). This function performs no
5883        // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
5884        // so a failure here can only be the kernel call itself, not an unrelated path resolution.
5885        let mut ruleset = Ruleset::default()
5886            .set_compatibility(CompatLevel::HardRequirement)
5887            .handle_access(handled)
5888            .map_err(|error| format!("landlock: handle_access failed: {error}"))?
5889            .create()
5890            .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
5891            .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
5892            .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
5893
5894        // The session's private `/proc`: read and list, nothing else. On `KernelSealed` the
5895        // composed root binds this same mount, so the rule reaches it there by the same inode.
5896        if let Some(proc_fd) = proc_fd {
5897            ruleset = ruleset
5898                .add_rule(PathBeneath::new(proc_fd.as_fd(), read_list & handled))
5899                .map_err(|error| {
5900                    format!("landlock: add_rule for the session's private /proc failed: {error}")
5901                })?;
5902        }
5903
5904        // The session's temporary folder, whenever the enforcement holds one. A rule on the
5905        // workdir does not reach a bind mounted over `/tmp`; see [`LandlockChildFds::temp_dir_fd`].
5906        if let Some(temp_dir_fd) = &fds.temp_dir_fd {
5907            ruleset = ruleset
5908                .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
5909                .map_err(|error| {
5910                    format!("landlock: add_rule for the session's temporary folder failed: {error}")
5911                })?;
5912        }
5913
5914        // The session's home folder, on exactly the temporary folder's terms: the workdir's
5915        // rights beneath the records directory's read-only rule.
5916        if let Some(home_dir_fd) = &fds.home_dir_fd {
5917            ruleset = ruleset
5918                .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
5919                .map_err(|error| {
5920                    format!("landlock: add_rule for the session's home folder failed: {error}")
5921                })?;
5922        }
5923
5924        // Every host path the operator's global config lends this session. A runtime folder
5925        // beneath one keeps its own rule below; on `KernelSealed` its bind is what keeps it
5926        // read-only, since Landlock rights accumulate down the tree.
5927        for (host_path_fd, read_write) in &fds.host_path_fds {
5928            ruleset = ruleset
5929                .add_rule(PathBeneath::new(
5930                    host_path_fd.as_fd(),
5931                    host_path_access_rights(*read_write) & handled,
5932                ))
5933                .map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
5934        }
5935
5936        // The runtime directory: readable and listable, never writable or executable.
5937        if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
5938            ruleset = ruleset
5939                .add_rule(PathBeneath::new(
5940                    runtime_dir_fd.as_fd(),
5941                    read_list & handled,
5942                ))
5943                .map_err(|error| {
5944                    format!("landlock: add_rule for the runtime directory failed: {error}")
5945                })?;
5946        }
5947
5948        // The records directory, on the same terms: the runtime appends to the trace from
5949        // outside the domain, and no command inside it can.
5950        if let Some(records_dir_fd) = &fds.records_dir_fd {
5951            ruleset = ruleset
5952                .add_rule(PathBeneath::new(
5953                    records_dir_fd.as_fd(),
5954                    read_list & handled,
5955                ))
5956                .map_err(|error| {
5957                    format!("landlock: add_rule for the records directory failed: {error}")
5958                })?;
5959        }
5960
5961        // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
5962        // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
5963        // the grant loop's rules for those two paths are taken on the host's files, not on these.
5964        for identity_fd in &fds.sealed_identity_fds {
5965            ruleset = ruleset
5966                .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5967                .map_err(|error| {
5968                    format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5969                })?;
5970        }
5971
5972        for grant in &fds.grants {
5973            // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
5974            // so every fd reaching here is valid. A rule that still fails to add is a genuine
5975            // ruleset-construction failure and propagates as `Err` (fail-closed).
5976            let access = match (grant.executable, grant.list_dir) {
5977                (true, true) => read_execute_list,
5978                (true, false) => read_execute,
5979                (false, true) => read_list,
5980                (false, false) => read_only,
5981            };
5982            ruleset = ruleset
5983                .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5984                .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5985        }
5986
5987        for device in &fds.devices {
5988            // Same convention as the grant loop above: a device that failed to open in the parent
5989            // was already dropped, so a failure here is a genuine ruleset-construction failure and
5990            // propagates as `Err` (fail-closed).
5991            let access = if device.writable {
5992                device_read_write
5993            } else {
5994                device_read
5995            };
5996            ruleset = ruleset
5997                .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5998                .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5999        }
6000
6001        ruleset
6002            .restrict_self()
6003            .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
6004
6005        Ok(())
6006    }
6007
6008    /// What [`open_landlock_fds`] opens, or carries through, for one launch.
6009    pub(super) struct LandlockFdRequest<'a> {
6010        /// The workdir the capsule's commands run in. Fail-closed: a workdir that cannot be
6011        /// opened is an `Err` naming the path.
6012        pub(super) accessible_workdir: &'a Path,
6013        /// The manifest-derived grants. A grant path that fails to open is dropped
6014        /// (shrink-not-fail).
6015        pub(super) landlock_grants: &'a [LandlockGrant],
6016        /// The tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on `KernelFull` and the
6017        /// composed root's own OCI default set on `KernelSealed`, chosen by
6018        /// `super::landlock_device_grants`. Opened by the grants' rules: the list is a
6019        /// compile-time constant rather than manifest-derived, and a host where one entry cannot
6020        /// be opened — unusual, but a broken or minimal `/dev` is possible — loses that one device
6021        /// rather than failing the launch. A lost device, `/dev/null` included, narrows the
6022        /// capsule's scope and never widens it.
6023        pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
6024        /// Carried into [`LandlockChildFds::workdir_exec`].
6025        pub(super) workdir_exec: bool,
6026        /// Carried into [`LandlockChildFds::abi`].
6027        pub(super) abi: Option<u8>,
6028        /// What only `KernelSealed` opens: the synthetic `/etc` files. Fail-closed.
6029        pub(super) sealed: Option<&'a super::SealedSourceFds>,
6030        /// The session's temporary folder's descriptor on both Landlock tiers, which
6031        /// `prepare_enforcement` already took and shares with the sealed binds. Fail-closed — an
6032        /// `Err` naming the path, exactly like the workdir's own open, never the grant loops'
6033        /// shrink-not-fail `continue`.
6034        pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6035        /// The session's home folder's descriptor, on the same terms as [`Self::temp_dir_fd`].
6036        pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6037        /// The runtime directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
6038        pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6039        /// The records directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
6040        pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6041        /// Every host path's descriptor, from [`open_host_path`], and whether it is
6042        /// `read-write`. Carried into [`LandlockChildFds::host_path_fds`].
6043        pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
6044    }
6045
6046    impl<'a> LandlockFdRequest<'a> {
6047        /// `accessible_workdir` and nothing else: no grants, no devices, no sealed sources and
6048        /// no directory fds.
6049        #[cfg(test)]
6050        pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
6051            Self {
6052                accessible_workdir,
6053                landlock_grants: &[],
6054                device_grants: &[],
6055                workdir_exec: false,
6056                abi: None,
6057                sealed: None,
6058                temp_dir_fd: None,
6059                home_dir_fd: None,
6060                runtime_dir_fd: None,
6061                records_dir_fd: None,
6062                host_path_fds: Vec::new(),
6063            }
6064        }
6065    }
6066
6067    /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
6068    /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
6069    /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
6070    /// now fails here, synchronously, with a message naming the path — before any subprocess is
6071    /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
6072    /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
6073    ///
6074    /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
6075    /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
6076    /// only, never widening.
6077    ///
6078    /// The request's devices are opened by the same rules as its grants, and its directory fds
6079    /// move into the returned [`LandlockChildFds`]. See [`LandlockFdRequest`] for each field.
6080    pub(super) fn open_landlock_fds(
6081        request: LandlockFdRequest<'_>,
6082    ) -> Result<LandlockChildFds, String> {
6083        let LandlockFdRequest {
6084            accessible_workdir,
6085            landlock_grants,
6086            device_grants,
6087            workdir_exec,
6088            abi,
6089            sealed,
6090            temp_dir_fd,
6091            home_dir_fd,
6092            runtime_dir_fd,
6093            records_dir_fd,
6094            host_path_fds,
6095        } = request;
6096        let workdir_fd = open_o_path(accessible_workdir)
6097            .map(OwnedFd::from)
6098            .map_err(|error| {
6099                format!(
6100                    "sandbox: failed to open workdir {} for Landlock scoping: {error}",
6101                    accessible_workdir.display()
6102                )
6103            })?;
6104
6105        // Same tier gate, same fail-closed handling: the composed root binds these two files at
6106        // `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of those
6107        // names, whose inodes the capsule never sees. See `LandlockChildFds`. `sealed-etc/` is
6108        // written by the runtime alone.
6109        let mut sealed_identity_fds = Vec::new();
6110        for path in sealed
6111            .map(|sources| sources.identity_files.as_slice())
6112            .unwrap_or_default()
6113        {
6114            let file = open_o_path(path).map_err(|error| {
6115                format!(
6116                    "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
6117                     {error}",
6118                    path.display()
6119                )
6120            })?;
6121            sealed_identity_fds.push(OwnedFd::from(file));
6122        }
6123
6124        let mut grants = Vec::with_capacity(landlock_grants.len());
6125        for grant in landlock_grants {
6126            match open_o_path(&grant.path) {
6127                Ok(file) => grants.push(OpenLandlockGrant {
6128                    // `ReadDir` is meaningful only on a directory, and the kernel does not merely
6129                    // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
6130                    // directory-only right on a non-directory, and the rule loop in
6131                    // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
6132                    // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
6133                    // `interpreter_runtime` entry, or the static `directory` bit on a
6134                    // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
6135                    // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
6136                    // never add it.
6137                    list_dir: grant.list_dir
6138                        && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
6139                    fd: OwnedFd::from(file),
6140                    executable: grant.executable,
6141                }),
6142                Err(_) => continue,
6143            }
6144        }
6145
6146        let mut devices = Vec::with_capacity(device_grants.len());
6147        for device in device_grants {
6148            match open_o_path(Path::new(device.path)) {
6149                Ok(file) => devices.push(OpenDeviceGrant {
6150                    fd: OwnedFd::from(file),
6151                    writable: device.writable,
6152                }),
6153                Err(_) => continue,
6154            }
6155        }
6156
6157        Ok(LandlockChildFds {
6158            workdir_fd,
6159            workdir_exec,
6160            abi,
6161            temp_dir_fd,
6162            home_dir_fd,
6163            sealed_identity_fds,
6164            runtime_dir_fd,
6165            records_dir_fd,
6166            host_path_fds,
6167            grants,
6168            devices,
6169        })
6170    }
6171
6172    /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
6173    /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
6174    /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
6175    /// closure.
6176    ///
6177    /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
6178    /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
6179    /// not perform a second path lookup to get it.
6180    fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
6181        use std::os::unix::fs::OpenOptionsExt;
6182        std::fs::OpenOptions::new()
6183            .read(true)
6184            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
6185            .open(path)
6186    }
6187
6188    /// An `O_PATH` handle on a host path, its Landlock rule's fd and its sealed bind's identity.
6189    /// `O_NOFOLLOW | O_DIRECTORY` on the directory the entry resolved to at launch: a link planted
6190    /// at its name since, or anything but a directory, fails the spawn naming the path.
6191    pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
6192        use std::os::unix::fs::OpenOptionsExt;
6193        std::fs::OpenOptions::new()
6194            .read(true)
6195            .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
6196            .open(path)
6197            .map(OwnedFd::from)
6198            .map_err(|error| {
6199                format!(
6200                    "sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
6201                     {error}",
6202                    path.display()
6203                )
6204            })
6205    }
6206
6207    /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
6208    /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
6209    /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
6210    /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
6211    /// returns `Err`, so `execve` is still aborted).
6212    #[allow(unsafe_code)]
6213    pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
6214        let bytes = message.as_bytes();
6215        let len = bytes.len().min(MAX_DIAG_LEN);
6216        let mut written = 0;
6217        while written < len {
6218            // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
6219            // name a live sub-slice of `bytes` for the duration of this single `write` call.
6220            let n = unsafe {
6221                libc::write(
6222                    fd,
6223                    bytes[written..len].as_ptr() as *const libc::c_void,
6224                    len - written,
6225                )
6226            };
6227            if n < 0 {
6228                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
6229                    continue;
6230                }
6231                break;
6232            }
6233            if n == 0 {
6234                break;
6235            }
6236            written += n as usize;
6237        }
6238    }
6239
6240    /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
6241    /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
6242    /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
6243    /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
6244    /// failure that never ran `pre_exec`).
6245    #[allow(unsafe_code)]
6246    pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
6247        let mut buf = Vec::new();
6248        let mut chunk = [0u8; 256];
6249        loop {
6250            // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
6251            // stack buffer of exactly the length passed.
6252            let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
6253            if n < 0 {
6254                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
6255                    continue;
6256                }
6257                break;
6258            }
6259            if n == 0 {
6260                break;
6261            }
6262            buf.extend_from_slice(&chunk[..n as usize]);
6263            if buf.len() >= MAX_DIAG_LEN {
6264                break;
6265            }
6266        }
6267        if buf.is_empty() {
6268            None
6269        } else {
6270            Some(String::from_utf8_lossy(&buf).into_owned())
6271        }
6272    }
6273
6274    /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
6275    /// against — all four numbers produced by running code on the host under test, never quoted.
6276    ///
6277    /// The written record these tests feed is
6278    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
6279    ///
6280    /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
6281    /// irreversible for the life of the task, so an in-process load would silently apply to the
6282    /// rest of the test binary and to everything it spawns. Every load happens in a forked child
6283    /// that does nothing else and then `_exit`s.
6284    #[cfg(test)]
6285    #[allow(clippy::print_stdout, clippy::print_stderr)]
6286    mod seccomp_budget {
6287        use std::io;
6288        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
6289
6290        // `seccomp_filter_instruction_count` is absent from this list because the test that
6291        // reports it carries the same name; it is called as `super::` throughout.
6292        use super::{
6293            build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
6294            LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
6295        };
6296        use crate::sandbox::{
6297            allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
6298            EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
6299            SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
6300        };
6301
6302        /// The kernel charges each filter already attached to a task four instructions on top of
6303        /// its own length when it decides whether the next attach fits under
6304        /// [`SECCOMP_MAX_INSNS_PER_PATH`].
6305        ///
6306        /// It is only part of the arithmetic. The length the kernel adds up is that of the
6307        /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
6308        /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
6309        /// out slightly longer. So a load count predicted from the exported classic length is an
6310        /// upper bound on the load count the kernel actually allows, never an equality, which is
6311        /// why the cumulative probe brackets it rather than pinning it.
6312        const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
6313
6314        /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
6315        /// Every real errno is a small positive number, and 0 is reserved for a load that
6316        /// succeeded, so this cannot collide with either.
6317        const PROBE_ERRNO_UNKNOWN: i32 = 254;
6318
6319        /// A filter built only to be measured or refused: default `Allow`, and `rules`
6320        /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
6321        ///
6322        /// Default `Allow` rather than the real filter's default deny, for two reasons that both
6323        /// matter. A child has to survive its own load long enough to `_exit` and, in the
6324        /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
6325        /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
6326        /// equals the default action, so the rules have to carry an action the default does not:
6327        /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
6328        /// every rule in the program.
6329        ///
6330        /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
6331        /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
6332        /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
6333        fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
6334            let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
6335                .map_err(to_io_err)?;
6336            filter.set_api_sysrawrc(true).map_err(to_io_err)?;
6337
6338            let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
6339            for value in 0..rules {
6340                filter
6341                    .add_rule_conditional(
6342                        libseccomp::ScmpAction::Errno(libc::EPERM),
6343                        syscall,
6344                        &[libseccomp::ScmpArgCompare::new(
6345                            0,
6346                            libseccomp::ScmpCompareOp::Equal,
6347                            value as u64,
6348                        )],
6349                    )
6350                    .map_err(to_io_err)?;
6351            }
6352
6353            let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
6354            Ok((filter, exported.len() / SOCK_FILTER_BYTES))
6355        }
6356
6357        /// The smallest probe-filter rule count whose compiled program is longer than
6358        /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
6359        /// monotonically with the rule count, so both halves are sound and the whole search
6360        /// costs a couple of dozen exports rather than thousands.
6361        fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
6362            let mut under = 1;
6363            let mut over = 8;
6364            while probe_filter(over)?.1 <= instructions {
6365                under = over;
6366                over *= 2;
6367                assert!(
6368                    over <= 1 << 20,
6369                    "no probe filter of up to {over} rules exceeded {instructions} instructions — \
6370                     the probe is not growing the program the way this search assumes"
6371                );
6372            }
6373            while over - under > 1 {
6374                let mid = under + (over - under) / 2;
6375                if probe_filter(mid)?.1 <= instructions {
6376                    under = mid;
6377                } else {
6378                    over = mid;
6379                }
6380            }
6381            Ok(over)
6382        }
6383
6384        /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
6385        /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
6386        /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
6387        /// only ever sees a plain `libc` value.
6388        fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
6389            use libseccomp::error::SeccompErrno;
6390
6391            if let Some(raw) = error.sysrawrc() {
6392                return -raw;
6393            }
6394            match error.errno() {
6395                Some(SeccompErrno::EACCES) => libc::EACCES,
6396                Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
6397                Some(SeccompErrno::EINVAL) => libc::EINVAL,
6398                Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
6399                _ => PROBE_ERRNO_UNKNOWN,
6400            }
6401        }
6402
6403        /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
6404        /// that took, `Err(errno)` for one the kernel refused.
6405        ///
6406        /// The filter is built by the caller, before the fork, so the child's whole post-fork
6407        /// window is `load()` and `_exit`.
6408        fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
6409            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
6410            // child confines itself to work that does not depend on another thread's state. This
6411            // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
6412            // the BPF program on every load and does not cache the one `export_bpf` produced —
6413            // so this probe is not async-signal-safe in the strict sense; it is bounded to a
6414            // child that touches nothing else and dies immediately.
6415            let pid = unsafe { libc::fork() };
6416            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6417
6418            if pid == 0 {
6419                let code = match filter.load() {
6420                    Ok(()) => 0,
6421                    Err(error) => refusal_errno(&error),
6422                };
6423                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
6424                // which is what keeps libtest's state in the parent untouched.
6425                unsafe { libc::_exit(code) }
6426            }
6427
6428            let mut status: libc::c_int = 0;
6429            // SAFETY: `pid` is the child just forked; `status` is a live local.
6430            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6431            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6432            assert!(
6433                libc::WIFEXITED(status),
6434                "the load probe child did not exit normally (status {status})"
6435            );
6436            match libc::WEXITSTATUS(status) {
6437                0 => Ok(()),
6438                errno => Err(errno),
6439            }
6440        }
6441
6442        /// The number of distinct syscalls the allowlist actually contributes a rule for on this
6443        /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
6444        /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
6445        /// is an independent measurement rather than a restatement of the filter under test.
6446        fn resolvable_allowlist_syscalls() -> io::Result<usize> {
6447            use std::collections::HashSet;
6448
6449            let mut scratch =
6450                libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
6451                    .map_err(to_io_err)?;
6452            let mut accepted = HashSet::new();
6453            for name in SECCOMP_SYSCALL_ALLOWLIST {
6454                if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
6455                    if scratch
6456                        .add_rule(libseccomp::ScmpAction::Allow, syscall)
6457                        .is_ok()
6458                    {
6459                        accepted.insert(i32::from(syscall));
6460                    }
6461                }
6462            }
6463            Ok(accepted.len())
6464        }
6465
6466        /// The line both ceiling probes print instead of running, on a host where no seccomp
6467        /// filter can be loaded at all.
6468        fn skip_without_seccomp(what: &str) -> bool {
6469            let tier = detect_enforcement_tier();
6470            if tier == EnforcementTier::EnvironmentOnly {
6471                eprintln!(
6472                    "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
6473                     can load a seccomp filter at all, which is every tier above \
6474                     EnforcementTier::EnvironmentOnly; detected {tier:?}"
6475                );
6476                return true;
6477            }
6478            false
6479        }
6480
6481        /// What the real filter compiles to, against the budget this project holds itself to and
6482        /// the ceiling the kernel holds it to.
6483        ///
6484        /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
6485        /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
6486        /// cover the larger of the two.
6487        #[test]
6488        fn seccomp_filter_instruction_count() {
6489            for unix_sockets_allowed in [false, true] {
6490                let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
6491                    .expect("export the built seccomp filter");
6492
6493                eprintln!(
6494                    "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
6495                     -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
6496                     per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
6497                     cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
6498                    BPF_MAXINSNS_CEILING as f64 / count as f64,
6499                    SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
6500                );
6501
6502                assert!(
6503                    count > 0,
6504                    "the exported filter is empty, so nothing was measured — \
6505                     `export_bpf` wrote no `struct sock_filter` records at all"
6506                );
6507                assert!(
6508                    count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
6509                    "the child's seccomp filter compiles to {count} instructions with \
6510                     unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
6511                     {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
6512                     {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
6513                     still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
6514                     deliberately — updating \
6515                     docs/content/reference/seccomp-filter-instruction-budget.md with the new \
6516                     numbers — rather than letting the filter grow into a spawn failure"
6517                );
6518            }
6519        }
6520
6521        /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
6522        /// pseudo-filter-code export so a change to the shape is a test failure rather than a
6523        /// silent change of policy.
6524        ///
6525        /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
6526        /// anywhere in this function would make libseccomp discard every argument-conditional
6527        /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
6528        /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
6529        #[test]
6530        fn seccomp_filter_rule_shape() {
6531            let allowlist_syscalls =
6532                resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
6533            assert!(
6534                allowlist_syscalls > 0,
6535                "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
6536                 for the wrong reason"
6537            );
6538
6539            for unix_sockets_allowed in [false, true] {
6540                let filter = build_seccomp_filter(unix_sockets_allowed, false)
6541                    .expect("build the seccomp filter");
6542                assert_eq!(
6543                    filter.get_act_default().expect("read the default action"),
6544                    libseccomp::ScmpAction::Errno(libc::EPERM),
6545                    "the filter's default action is no longer a deny with EPERM, which is what \
6546                     makes every syscall outside the allowlist unreachable"
6547                );
6548
6549                let pfc = seccomp_filter_pfc(unix_sockets_allowed, false)
6550                    .expect("export the filter's pfc");
6551                let denied = denied_socket_domains(unix_sockets_allowed);
6552                let allowed = allowed_socket_domains(unix_sockets_allowed);
6553
6554                // One block per syscall carrying at least one rule: `socket`, every allowlist
6555                // name libseccomp accepted, and the three Landlock syscalls.
6556                let syscall_blocks = pfc.matches("# filter for syscall ").count();
6557                assert_eq!(
6558                    syscall_blocks,
6559                    1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
6560                    "the built filter carries rules for {syscall_blocks} syscalls, not the \
6561                     socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
6562                     syscalls this code adds",
6563                    LANDLOCK_SYSCALLS.len()
6564                );
6565
6566                let deny_actions = pfc
6567                    .matches(&format!("action ERRNO({});", libc::EACCES))
6568                    .count();
6569                assert_eq!(
6570                    deny_actions,
6571                    denied.len(),
6572                    "the built filter carries {deny_actions} EACCES rules, not one per denied \
6573                     socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
6574                     exactly like this, and would have discarded every domain denial with it"
6575                );
6576
6577                // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
6578                // contributes one per allowed domain.
6579                let allow_actions = pfc.matches("action ALLOW;").count();
6580                assert_eq!(
6581                    allow_actions,
6582                    allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
6583                    "the built filter carries {allow_actions} Allow rules, not one per allowed \
6584                     socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
6585                     Landlock syscall"
6586                );
6587
6588                for (name, _) in LANDLOCK_SYSCALLS {
6589                    assert!(
6590                        pfc.contains(&format!("\"{name}\"")),
6591                        "{name} has no rule in the built filter, so `apply_landlock_scope` would \
6592                         be refused by the filter installed just before it"
6593                    );
6594                }
6595
6596                eprintln!(
6597                    "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
6598                     default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
6599                     {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
6600                    denied.len(),
6601                    allowed.len(),
6602                    LANDLOCK_SYSCALLS.len(),
6603                );
6604            }
6605        }
6606
6607        /// The `bind` block of a pfc export: from its `# filter for syscall "bind"` header to the
6608        /// next block.
6609        fn bind_block(pfc: &str) -> &str {
6610            let start = pfc
6611                .find("# filter for syscall \"bind\"")
6612                .unwrap_or_else(|| panic!("no bind block in:\n{pfc}"));
6613            let rest = &pfc[start + 1..];
6614            let end = rest
6615                .find("# filter for syscall ")
6616                .map_or(pfc.len(), |at| start + 1 + at);
6617            &pfc[start..end]
6618        }
6619
6620        /// How many `BPF_RET | BPF_K` instructions of `filter`'s export return `action`.
6621        fn returns_of(filter: &libseccomp::ScmpFilterContext, action: u32) -> usize {
6622            let exported = export_to_memfd(|fd| filter.export_bpf(fd)).expect("export the bpf");
6623            let (insns, _) = exported.as_chunks::<SOCK_FILTER_BYTES>();
6624            insns
6625                .iter()
6626                .filter(|insn| {
6627                    let code = u16::from_ne_bytes([insn[0], insn[1]]);
6628                    let k = u32::from_ne_bytes([insn[4], insn[5], insn[6], insn[7]]);
6629                    code == (libc::BPF_RET | libc::BPF_K) as u16 && k == action
6630                })
6631                .count()
6632        }
6633
6634        /// The observed filter routes `bind` to the notify action (`SECCOMP_RET_USER_NOTIF`,
6635        /// `0x7fc00000`) in place of its `Allow`, and nothing else; the filter without
6636        /// observation allows `bind` and notifies on nothing.
6637        #[test]
6638        fn seccomp_observed_filter_notifies_on_bind_instead_of_allowing_it() {
6639            const SECCOMP_RET_USER_NOTIF: u32 = 0x7fc0_0000;
6640            for unix_sockets_allowed in [false, true] {
6641                let plain = seccomp_filter_pfc(unix_sockets_allowed, false).expect("plain pfc");
6642                let observed =
6643                    seccomp_filter_pfc(unix_sockets_allowed, true).expect("observed pfc");
6644                eprintln!(
6645                    "unix_sockets_allowed={unix_sockets_allowed}\nplain bind block:\n{}\
6646                     observed bind block:\n{}",
6647                    bind_block(&plain),
6648                    bind_block(&observed)
6649                );
6650
6651                // libseccomp's pfc writes the notify action as its raw value.
6652                let notify = format!("action {SECCOMP_RET_USER_NOTIF:#x};");
6653                assert!(bind_block(&plain).contains("action ALLOW;"));
6654                assert!(!plain.contains(&notify), "{plain}");
6655                assert!(bind_block(&observed).contains(&notify));
6656                assert!(!bind_block(&observed).contains("ALLOW"));
6657                assert_eq!(observed.matches(&notify).count(), 1, "{observed}");
6658                assert_eq!(
6659                    observed.matches("action ALLOW;").count() + 1,
6660                    plain.matches("action ALLOW;").count()
6661                );
6662                assert_eq!(
6663                    observed.matches("# filter for syscall ").count(),
6664                    plain.matches("# filter for syscall ").count()
6665                );
6666
6667                let plain = build_seccomp_filter(unix_sockets_allowed, false).unwrap();
6668                let observed = build_seccomp_filter(unix_sockets_allowed, true).unwrap();
6669                assert_eq!(returns_of(&plain, SECCOMP_RET_USER_NOTIF), 0);
6670                assert!(returns_of(&observed, SECCOMP_RET_USER_NOTIF) >= 1);
6671            }
6672        }
6673
6674        /// The errno a forked child gets when, after loading `first`, it asks for a second
6675        /// seccomp filter with a listener of its own (`SECCOMP_FILTER_FLAG_NEW_LISTENER`): `0` if
6676        /// the kernel took it.
6677        fn second_listener_errno(first: &libseccomp::ScmpFilterContext) -> i32 {
6678            let allow = [libc::sock_filter {
6679                code: (libc::BPF_RET | libc::BPF_K) as u16,
6680                jt: 0,
6681                jf: 0,
6682                k: libc::SECCOMP_RET_ALLOW,
6683            }];
6684            let program = libc::sock_fprog {
6685                len: allow.len() as u16,
6686                filter: allow.as_ptr().cast_mut(),
6687            };
6688            // SAFETY: as `load_in_child`: the child calls `load()`, one raw `seccomp(2)` over the
6689            // program built above, reads `errno` and `_exit`s.
6690            let pid = unsafe { libc::fork() };
6691            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6692            if pid == 0 {
6693                let code = match first.load() {
6694                    Err(_) => PROBE_ERRNO_UNKNOWN,
6695                    // SAFETY: `program` points at `allow`, both alive in this child; `errno` is
6696                    // read right after the call that set it.
6697                    Ok(()) => unsafe {
6698                        if libc::syscall(
6699                            libc::SYS_seccomp,
6700                            libc::SECCOMP_SET_MODE_FILTER,
6701                            libc::SECCOMP_FILTER_FLAG_NEW_LISTENER,
6702                            std::ptr::addr_of!(program),
6703                        ) >= 0
6704                        {
6705                            0
6706                        } else {
6707                            *libc::__errno_location()
6708                        }
6709                    },
6710                };
6711                // SAFETY: forked-child context; `_exit` runs no destructor.
6712                unsafe { libc::_exit(code) }
6713            }
6714            let mut status: libc::c_int = 0;
6715            // SAFETY: `pid` is the child just forked; `status` is a live local.
6716            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6717            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6718            assert!(libc::WIFEXITED(status), "status {status}");
6719            libc::WEXITSTATUS(status)
6720        }
6721
6722        /// A program inside an observed command that installs a seccomp user-notification
6723        /// listener of its own is refused. Under the shipped filter the refusal is `EPERM`, the
6724        /// default action for `seccomp(2)`, which is outside the allowlist with or without
6725        /// observation; behind it, the kernel's one-listener-per-chain rule refuses a second
6726        /// listener with `EBUSY`, measured here under a filter that permits `seccomp(2)`.
6727        #[test]
6728        fn a_second_listener_under_the_observed_filter_is_refused() {
6729            if skip_without_seccomp("a second seccomp listener under the observed filter") {
6730                return;
6731            }
6732            let observed = build_seccomp_filter(false, true).expect("build the observed filter");
6733            let mut permissive =
6734                libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow).unwrap();
6735            permissive
6736                .add_rule(
6737                    libseccomp::ScmpAction::Notify,
6738                    libseccomp::ScmpSyscall::from_name("bind").unwrap(),
6739                )
6740                .unwrap();
6741
6742            let shipped = second_listener_errno(&observed);
6743            let kernel = second_listener_errno(&permissive);
6744            eprintln!(
6745                "second listener under the observed filter: errno {shipped} ({}); under a \
6746                 filter permitting seccomp(2): errno {kernel} ({})",
6747                io::Error::from_raw_os_error(shipped),
6748                io::Error::from_raw_os_error(kernel)
6749            );
6750            assert_eq!(shipped, libc::EPERM);
6751            assert_eq!(kernel, libc::EBUSY);
6752        }
6753
6754        /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
6755        /// two filters that straddle it and observing that the kernel takes one and refuses the
6756        /// other with `EINVAL`.
6757        ///
6758        /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
6759        /// ceiling, whatever the filter's length.
6760        #[test]
6761        fn seccomp_per_filter_ceiling() {
6762            if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
6763                return;
6764            }
6765
6766            let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
6767                .expect("bracket the per-filter ceiling");
6768            let (under_filter, under_count) =
6769                probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
6770            let (over_filter, over_count) =
6771                probe_filter(over_rules).expect("build the over-ceiling probe filter");
6772
6773            assert!(
6774                under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
6775                "the two probe filters ({under_count} and {over_count} instructions) do not \
6776                 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
6777                 nothing about where the ceiling is"
6778            );
6779
6780            let under_outcome = load_in_child(&under_filter);
6781            let over_outcome = load_in_child(&over_filter);
6782
6783            eprintln!(
6784                "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
6785                 instructions; smallest that did not = {over_count} instructions, refused with \
6786                 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
6787                over_outcome.err().unwrap_or(0),
6788                libc::EINVAL,
6789            );
6790
6791            assert_eq!(
6792                under_outcome,
6793                Ok(()),
6794                "a {under_count}-instruction filter was refused, below the \
6795                 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
6796            );
6797            assert_eq!(
6798                over_outcome,
6799                Err(libc::EINVAL),
6800                "a {over_count}-instruction filter was not refused with EINVAL, so \
6801                 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
6802                 per-filter limit"
6803            );
6804        }
6805
6806        /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
6807        /// task carries, by stacking filters in a forked child until the kernel refuses.
6808        ///
6809        /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
6810        /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
6811        /// filter of a few hundred instructions, and that had not yet reached `load()` when it
6812        /// failed, did not come from the kernel's seccomp code.
6813        #[test]
6814        fn seccomp_cumulative_ceiling() {
6815            if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
6816                return;
6817            }
6818
6819            // A probe of roughly the shipped filter's size, so the number of stacked loads reads
6820            // directly as "how many filters like ours one task could carry".
6821            let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
6822            let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
6823            // Enough headroom past the ceiling that a kernel which never refuses is caught by the
6824            // cap rather than by the test hanging.
6825            let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
6826
6827            let mut fds = [0 as libc::c_int; 2];
6828            // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
6829            let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
6830            assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
6831            // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
6832            let (read_fd, write_fd) =
6833                unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
6834
6835            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
6836            // child confines itself to work that does not depend on another thread's state. This
6837            // child calls `load()` in a loop, writes eight bytes and `_exit`s.
6838            let pid = unsafe { libc::fork() };
6839            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6840
6841            if pid == 0 {
6842                let mut loads: u32 = 0;
6843                let mut refusal: i32 = 0;
6844                while loads < max_loads as u32 {
6845                    match filter.load() {
6846                        Ok(()) => loads += 1,
6847                        Err(error) => {
6848                            refusal = refusal_errno(&error);
6849                            break;
6850                        }
6851                    }
6852                }
6853
6854                let mut report = [0u8; 8];
6855                report[..4].copy_from_slice(&loads.to_ne_bytes());
6856                report[4..].copy_from_slice(&refusal.to_ne_bytes());
6857                // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
6858                // local of exactly the length passed, and the descriptor is the pipe's write end.
6859                unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
6860                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
6861                unsafe { libc::_exit(0) }
6862            }
6863
6864            drop(write_fd);
6865            let mut report = [0u8; 8];
6866            std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
6867                .expect("read the cumulative probe child's report");
6868            let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
6869            let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
6870
6871            let mut status: libc::c_int = 0;
6872            // SAFETY: `pid` is the child just forked; `status` is a live local.
6873            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6874            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6875
6876            let stacked = loads * count;
6877            let cumulative_at_refusal = count + loads * per_filter_cost;
6878            let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
6879            let shipped = super::seccomp_filter_instruction_count(false)
6880                .expect("measure the shipped filter for comparison");
6881
6882            eprintln!(
6883                "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
6884                 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
6885                 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
6886                 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
6887                 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
6888                 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
6889                 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
6890                 instructions, so reaching this ceiling would take {} of them in a single task.",
6891                loads + 1,
6892                libc::ENOMEM,
6893                SECCOMP_MAX_INSNS_PER_PATH / shipped,
6894            );
6895
6896            assert!(
6897                loads >= 2,
6898                "the kernel refused the filter chain after only {loads} load(s), so this is not \
6899                 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
6900            );
6901            assert_eq!(
6902                refusal,
6903                libc::ENOMEM,
6904                "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
6905                 {refusal}, not ENOMEM ({}) — the rule-out in \
6906                 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
6907                 being what this ceiling returns",
6908                libc::ENOMEM
6909            );
6910            assert!(
6911                loads <= loads_if_counted_classic,
6912                "the kernel took {loads} loads of a {count}-instruction filter — more than the \
6913                 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
6914                 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
6915                 each one, so this kernel's ceiling is higher than the recorded value"
6916            );
6917            // Loose on purpose: the exported classic length under-counts what the kernel adds
6918            // up, so the bound below tolerates any plausible conversion expansion while still
6919            // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
6920            // say, BPF_MAXINSNS would fail here.
6921            assert!(
6922                loads * 2 > loads_if_counted_classic,
6923                "the kernel refused after {loads} loads of a {count}-instruction filter, far \
6924                 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
6925                 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
6926                 one recorded"
6927            );
6928            assert!(
6929                shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
6930                "the shipped filter ({shipped} instructions) is within a factor of two of the \
6931                 cumulative ceiling, which would make the rule-out this test supports untrue"
6932            );
6933        }
6934    }
6935}
6936
6937/// Builds a minimal but well-formed ELF64 image exercising exactly the fields
6938/// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
6939/// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
6940/// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
6941#[cfg(test)]
6942pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6943    let has_dynamic = !needed.is_empty() || runpath.is_some();
6944
6945    let mut phnum: u16 = 1; // PT_LOAD
6946    if interp.is_some() {
6947        phnum += 1;
6948    }
6949    if has_dynamic {
6950        phnum += 1;
6951    }
6952
6953    let ehsize = 64usize;
6954    let phentsize = 56usize;
6955    let phoff = ehsize;
6956    let body_start = phoff + phentsize * phnum as usize;
6957
6958    // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
6959    let mut strtab: Vec<u8> = vec![0];
6960    let mut needed_offsets = Vec::new();
6961    for name in needed {
6962        needed_offsets.push(strtab.len() as u64);
6963        strtab.extend_from_slice(name.as_bytes());
6964        strtab.push(0);
6965    }
6966    let runpath_offset = runpath.map(|r| {
6967        let off = strtab.len() as u64;
6968        strtab.extend_from_slice(r.as_bytes());
6969        strtab.push(0);
6970        off
6971    });
6972
6973    let mut body: Vec<u8> = Vec::new();
6974
6975    let interp_offset = body_start + body.len();
6976    if let Some(i) = interp {
6977        body.extend_from_slice(i.as_bytes());
6978        body.push(0);
6979    }
6980
6981    // Align the dynamic array to 8 bytes.
6982    while !(body_start + body.len()).is_multiple_of(8) {
6983        body.push(0);
6984    }
6985    let dyn_offset = body_start + body.len();
6986    let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
6987    let dyn_size = num_dyn * 16;
6988    let strtab_offset = dyn_offset + dyn_size;
6989
6990    if has_dynamic {
6991        let mut push_dyn = |tag: u64, val: u64| {
6992            body.extend_from_slice(&tag.to_le_bytes());
6993            body.extend_from_slice(&val.to_le_bytes());
6994        };
6995        for &o in &needed_offsets {
6996            push_dyn(1, o); // DT_NEEDED
6997        }
6998        if let Some(ro) = runpath_offset {
6999            push_dyn(29, ro); // DT_RUNPATH
7000        }
7001        push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
7002        push_dyn(10, strtab.len() as u64); // DT_STRSZ
7003        push_dyn(0, 0); // DT_NULL
7004        assert_eq!(body_start + body.len(), strtab_offset);
7005        body.extend_from_slice(&strtab);
7006    }
7007
7008    let total_len = body_start + body.len();
7009    let mut out = vec![0u8; total_len];
7010    out[0..4].copy_from_slice(b"\x7fELF");
7011    out[4] = 2; // ELFCLASS64
7012    out[5] = 1; // little-endian
7013    out[6] = 1; // EI_VERSION
7014    out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
7015    out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
7016    out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
7017    out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
7018    out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
7019    out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
7020
7021    let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
7022    phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
7023    if let Some(i) = interp {
7024        phdrs.push((
7025            3,
7026            interp_offset as u64,
7027            interp_offset as u64,
7028            i.len() as u64 + 1,
7029        ));
7030    }
7031    if has_dynamic {
7032        phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
7033    }
7034    for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
7035        let base = phoff + idx * phentsize;
7036        out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
7037        out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
7038        out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
7039        out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
7040    }
7041
7042    out[body_start..total_len].copy_from_slice(&body);
7043    out
7044}
7045
7046#[cfg(test)]
7047mod tests {
7048    use super::*;
7049    use murmur_artifact::security_warnings::W_SEC_003;
7050
7051    /// Content check only: this asserts the *constant*, not the security property. Spawning a
7052    /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
7053    /// enforcement, so it would read as evidence while proving nothing.
7054    ///
7055    /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
7056    /// breaks every subprocess invocation.
7057    #[test]
7058    fn fd_hygiene_range_starts_above_stdio() {
7059        assert_eq!(
7060            FD_HYGIENE_FIRST_FD, 3,
7061            "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
7062             survive execve"
7063        );
7064    }
7065
7066    /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
7067    /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
7068    /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
7069    /// runs without fd hygiene.
7070    ///
7071    /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
7072    /// argument validation before it touches any descriptor — so this exercises the errno path
7073    /// without closing or flagging a single fd of the test process. A kernel older than 5.11
7074    /// rejects the production call with a different errno and the identical convention — see the
7075    /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
7076    #[cfg(target_os = "linux")]
7077    #[allow(unsafe_code)]
7078    #[test]
7079    fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
7080        // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
7081        // bit is set here, so the kernel rejects the call on argument validation and no descriptor
7082        // of this process is closed or marked close-on-exec.
7083        let rc = unsafe {
7084            libc::syscall(
7085                libc::SYS_close_range,
7086                FD_HYGIENE_FIRST_FD,
7087                libc::c_uint::MAX,
7088                libc::c_uint::MAX,
7089            )
7090        };
7091
7092        assert_eq!(rc, -1, "an invalid flag word must not be accepted");
7093        assert_eq!(
7094            std::io::Error::last_os_error().raw_os_error(),
7095            Some(libc::EINVAL),
7096            "the two statements the production path runs on failure must carry the kernel's errno"
7097        );
7098    }
7099
7100    /// A host whose probe created all three namespaces — read under the AppArmor restriction with
7101    /// `mur` unconfined, because the readings never decide anything.
7102    fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
7103        crate::namespace_probe::NamespaceReading {
7104            network: Ok(()),
7105            mount: crate::namespace_probe::MountAttempt::Created,
7106            sealed_root: crate::namespace_probe::MountAttempt::Created,
7107            private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
7108            userns_grant: crate::sealed::UsernsGrant::Withheld,
7109            attachment: crate::sealed::ProfileAttachment::Unconfined,
7110            profile_gap: crate::sealed::ProfileGap::Undetermined,
7111        }
7112    }
7113
7114    /// A host whose probe could not run at all, which claims nothing.
7115    fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
7116        crate::namespace_probe::NamespaceReading {
7117            network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
7118                "the namespace creator did not run".to_string(),
7119            )),
7120            mount: crate::namespace_probe::MountAttempt::NotAttempted,
7121            sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
7122            private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
7123            userns_grant: crate::sealed::UsernsGrant::Withheld,
7124            attachment: crate::sealed::ProfileAttachment::NotReported,
7125            profile_gap: crate::sealed::ProfileGap::Undetermined,
7126        }
7127    }
7128
7129    /// `mur doctor`'s shell mechanism is read from the decision on the reading it is handed: on a
7130    /// sealed-capable reading a capsule declaring `scoped` runs its shell commands on
7131    /// `landlock+seccomp` and one declaring `sealed` on the composed root.
7132    #[test]
7133    fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
7134        if !cfg!(target_os = "linux") {
7135            return;
7136        }
7137        let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
7138        assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
7139        assert_eq!(
7140            crate::containment::applied_mechanism(
7141                &sealed_host,
7142                murmur_artifact::ContainmentClass::Scoped
7143            ),
7144            "landlock+seccomp"
7145        );
7146        assert_eq!(
7147            crate::containment::applied_mechanism(
7148                &sealed_host,
7149                murmur_artifact::ContainmentClass::Sealed
7150            ),
7151            "mountns+pivot_root+landlock+seccomp"
7152        );
7153    }
7154
7155    /// A probe that learned nothing: no Landlock answer and a namespace probe that could not run.
7156    /// The reading a host gives when neither half of the probe could run.
7157    fn unprobed() -> HostProbe {
7158        HostProbe::from_parts(None, sealed_incapable())
7159    }
7160
7161    /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
7162    /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
7163    /// tier is decided from a reading.
7164    #[test]
7165    fn an_unprobed_host_resolves_to_seccomp_only() {
7166        let tier = unprobed().tier();
7167        if cfg!(target_os = "linux") {
7168            assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
7169        } else {
7170            assert_eq!(tier, EnforcementTier::EnvironmentOnly);
7171        }
7172        assert_ne!(tier, EnforcementTier::KernelSealed);
7173        assert_ne!(tier, EnforcementTier::KernelFull);
7174    }
7175
7176    /// A reading that demonstrated nothing must still name what is missing: a refusal with no
7177    /// blocker is a refusal an operator cannot act on.
7178    #[test]
7179    fn an_unprobed_host_names_a_blocker() {
7180        assert!(unprobed().sealed_shortfall().is_some());
7181    }
7182
7183    /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
7184    /// so nothing downstream can read a failed probe as a met floor.
7185    #[test]
7186    fn an_unprobed_host_achieves_less_than_sealed() {
7187        let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
7188        assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
7189    }
7190
7191    /// The whole point of the direction: a capsule declaring `sealed` against a reading that
7192    /// demonstrated nothing is refused, with the shortfall naming the mechanism.
7193    #[test]
7194    fn an_unprobed_host_refuses_a_declared_sealed_floor() {
7195        let probe = unprobed();
7196        let achieved = crate::containment::achieved_class_for_tier(probe.tier());
7197        let error = crate::containment::check_containment_floor(
7198            murmur_artifact::ContainmentClass::Sealed,
7199            achieved,
7200            probe.sealed_shortfall().as_ref(),
7201            false,
7202            None,
7203        )
7204        .expect_err("an unprobed host cannot back a declared sealed floor");
7205        assert!(matches!(
7206            error,
7207            crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
7208        ));
7209    }
7210
7211    /// The probe succeeding while the AppArmor setting reads `1`: all three attempts created,
7212    /// `mur` unconfined or confined by a container's profile. The host backs `sealed`, maps
7213    /// `/tmp`, and is refused nothing.
7214    #[test]
7215    fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
7216        for attachment in [
7217            crate::sealed::ProfileAttachment::Unconfined,
7218            crate::sealed::ProfileAttachment::Other {
7219                profile: "docker-default".to_string(),
7220            },
7221        ] {
7222            let probe = HostProbe::from_parts(
7223                Some(true),
7224                crate::namespace_probe::NamespaceReading {
7225                    attachment,
7226                    ..sealed_capable()
7227                },
7228            );
7229            if !cfg!(target_os = "linux") {
7230                assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
7231                continue;
7232            }
7233            assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
7234            assert_eq!(probe.sealed_shortfall(), None);
7235            assert!(probe.mount_namespace_usable());
7236            assert_eq!(probe.egress_namespace_failure(), None);
7237            assert_eq!(
7238                crate::temp_dir::temp_mapping(
7239                    EnforcementTier::KernelFull,
7240                    probe.mount_namespace_usable(),
7241                    Path::new("/home/u/w"),
7242                ),
7243                crate::temp_dir::TempMapping::MountNamespace
7244            );
7245        }
7246    }
7247
7248    /// No decision reads the grant: over every reading of the restriction, the tier, the
7249    /// shortfall, the `/tmp` question and the network failure are the same — for a probe that
7250    /// created everything, and for one that created only the network and mount namespaces.
7251    #[test]
7252    fn every_decision_is_identical_across_every_userns_grant() {
7253        use crate::namespace_probe::{
7254            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
7255        };
7256        let root_refused = NamespaceReading {
7257            sealed_root: MountAttempt::Failed(MountAttemptError::Step {
7258                step: MountStep::PivotRoot,
7259                errno: libc::EPERM,
7260            }),
7261            ..sealed_capable()
7262        };
7263        for base in [sealed_capable(), root_refused, sealed_incapable()] {
7264            let decide = |grant: crate::sealed::UsernsGrant| {
7265                let probe = HostProbe::from_parts(
7266                    Some(true),
7267                    NamespaceReading {
7268                        userns_grant: grant,
7269                        ..base.clone()
7270                    },
7271                );
7272                (
7273                    probe.tier(),
7274                    probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
7275                    probe.mount_namespace_usable(),
7276                    probe
7277                        .egress_namespace_failure()
7278                        .map(|failure| failure.blocker),
7279                )
7280            };
7281            let expected = decide(crate::sealed::UsernsGrant::Withheld);
7282            for grant in crate::sealed::UsernsGrant::ALL {
7283                assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
7284            }
7285        }
7286    }
7287
7288    /// On whatever host this runs, one live reading is `KernelSealed` exactly when it names no
7289    /// `sealed` shortfall, maps `/tmp` exactly when the network and mount attempts created their
7290    /// namespaces, and reports `E-CAP-005` exactly when the network attempt failed.
7291    #[test]
7292    fn a_live_reading_answers_every_question_from_its_own_attempts() {
7293        let probe = HostProbe::probe();
7294        let namespaces = probe.namespaces().clone();
7295        let linux = cfg!(target_os = "linux");
7296        assert_eq!(
7297            probe.tier() == EnforcementTier::KernelSealed,
7298            probe.sealed_shortfall().is_none(),
7299            "{namespaces:?}"
7300        );
7301        assert_eq!(
7302            probe.mount_namespace_usable(),
7303            linux
7304                && namespaces.network.is_ok()
7305                && namespaces.mount == crate::namespace_probe::MountAttempt::Created
7306        );
7307        assert_eq!(
7308            probe.egress_namespace_failure().is_some(),
7309            linux && namespaces.network.is_err()
7310        );
7311    }
7312
7313    #[test]
7314    fn tier_from_probe_non_linux_is_always_environment_only() {
7315        for landlock in [None, Some(true), Some(false)] {
7316            for namespaces in [sealed_capable(), sealed_incapable()] {
7317                assert_eq!(
7318                    tier_from_probe(false, landlock, &namespaces),
7319                    EnforcementTier::EnvironmentOnly
7320                );
7321            }
7322        }
7323    }
7324
7325    #[test]
7326    fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
7327        assert_eq!(
7328            tier_from_probe(true, Some(true), &sealed_incapable()),
7329            EnforcementTier::KernelFull
7330        );
7331    }
7332
7333    #[test]
7334    fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
7335        assert_eq!(
7336            tier_from_probe(true, Some(false), &sealed_incapable()),
7337            EnforcementTier::KernelSeccompOnly
7338        );
7339    }
7340
7341    #[test]
7342    fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
7343        assert_eq!(
7344            tier_from_probe(true, None, &sealed_incapable()),
7345            EnforcementTier::KernelSeccompOnly
7346        );
7347    }
7348
7349    /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a fully
7350    /// enforced Landlock ruleset and a sealed-root rehearsal that built the composed root — and
7351    /// drops to exactly the tier the missing element still supports, never further.
7352    #[test]
7353    fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
7354        use crate::namespace_probe::{
7355            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
7356        };
7357
7358        assert_eq!(
7359            tier_from_probe(true, Some(true), &sealed_capable()),
7360            EnforcementTier::KernelSealed
7361        );
7362
7363        // A refused network namespace attempts nothing after it: `scoped`, not `advisory`.
7364        let network_refused = NamespaceReading {
7365            network: Err(crate::network_namespace::NamespaceAttemptError::Step {
7366                step: crate::network_namespace::CreationStep::IdentityMaps,
7367                errno: libc::EPERM,
7368            }),
7369            mount: MountAttempt::NotAttempted,
7370            sealed_root: MountAttempt::NotAttempted,
7371            ..sealed_capable()
7372        };
7373        assert_eq!(
7374            tier_from_probe(true, Some(true), &network_refused),
7375            EnforcementTier::KernelFull
7376        );
7377
7378        // Every way the sealed-root rehearsal can fail falls back to KernelFull, never below.
7379        for step in [
7380            MountStep::JoinSession,
7381            MountStep::UnshareUserMount,
7382            MountStep::IdentityMaps,
7383            MountStep::MakePrivateNested,
7384            MountStep::Tmpfs,
7385            MountStep::PivotPrepare,
7386            MountStep::PivotRoot,
7387            MountStep::DetachOldRoot,
7388        ] {
7389            let reading = NamespaceReading {
7390                sealed_root: MountAttempt::Failed(MountAttemptError::Step {
7391                    step,
7392                    errno: libc::EPERM,
7393                }),
7394                ..sealed_capable()
7395            };
7396            assert_eq!(
7397                tier_from_probe(true, Some(true), &reading),
7398                EnforcementTier::KernelFull,
7399                "a sealed root refused at {step:?} must fall back to KernelFull"
7400            );
7401        }
7402
7403        // The `/tmp` attempt is not the composed root's: refusing it leaves `sealed` reachable.
7404        let tmp_refused = NamespaceReading {
7405            mount: MountAttempt::Failed(MountAttemptError::Step {
7406                step: MountStep::MakePrivate,
7407                errno: libc::EACCES,
7408            }),
7409            ..sealed_capable()
7410        };
7411        assert_eq!(
7412            tier_from_probe(true, Some(true), &tmp_refused),
7413            EnforcementTier::KernelSealed
7414        );
7415
7416        // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
7417        // cannot reach sealed no matter how good its namespaces are.
7418        assert_eq!(
7419            tier_from_probe(true, Some(false), &sealed_capable()),
7420            EnforcementTier::KernelSeccompOnly
7421        );
7422        assert_eq!(
7423            tier_from_probe(true, None, &sealed_capable()),
7424            EnforcementTier::KernelSeccompOnly
7425        );
7426
7427        // Not Linux at all.
7428        assert_eq!(
7429            tier_from_probe(false, Some(true), &sealed_capable()),
7430            EnforcementTier::EnvironmentOnly
7431        );
7432    }
7433
7434    /// `/tmp` follows the probe's mount attempt: refused at `MS_REC|MS_PRIVATE` it is not mapped;
7435    /// created with the sealed root refused it is mapped at `KernelFull`; with the network
7436    /// namespace refused there is no mount namespace to map it in.
7437    #[test]
7438    fn the_tmp_mapping_follows_the_mount_attempt() {
7439        use crate::namespace_probe::{
7440            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
7441        };
7442        use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
7443        if !cfg!(target_os = "linux") {
7444            return;
7445        }
7446        let workdir = Path::new("/home/u/w");
7447        let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
7448
7449        let mount_refused = HostProbe::from_parts(
7450            Some(true),
7451            NamespaceReading {
7452                mount: refused(MountStep::MakePrivate, libc::EACCES),
7453                sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
7454                ..sealed_capable()
7455            },
7456        );
7457        assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
7458        assert_eq!(
7459            temp_mapping(
7460                mount_refused.tier(),
7461                mount_refused.mount_namespace_usable(),
7462                workdir
7463            ),
7464            TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
7465        );
7466
7467        let root_refused = HostProbe::from_parts(
7468            Some(true),
7469            NamespaceReading {
7470                sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
7471                ..sealed_capable()
7472            },
7473        );
7474        assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
7475        assert_eq!(
7476            temp_mapping(
7477                root_refused.tier(),
7478                root_refused.mount_namespace_usable(),
7479                workdir
7480            ),
7481            TempMapping::MountNamespace
7482        );
7483
7484        let network_refused = HostProbe::from_parts(
7485            Some(true),
7486            NamespaceReading {
7487                network: Err(crate::network_namespace::NamespaceAttemptError::Step {
7488                    step: crate::network_namespace::CreationStep::Unshare,
7489                    errno: libc::EPERM,
7490                }),
7491                mount: MountAttempt::NotAttempted,
7492                sealed_root: MountAttempt::NotAttempted,
7493                ..sealed_capable()
7494            },
7495        );
7496        assert!(!network_refused.mount_namespace_usable());
7497        assert!(matches!(
7498            temp_mapping(
7499                network_refused.tier(),
7500                network_refused.mount_namespace_usable(),
7501                workdir
7502            ),
7503            TempMapping::NotMapped(_)
7504        ));
7505        assert!(network_refused.egress_namespace_failure().is_some());
7506    }
7507
7508    /// The ruleset ABI is the kernel's, capped at v3, on the two Landlock tiers only.
7509    #[test]
7510    fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
7511        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
7512            for (kernel, built) in [
7513                (Some(1), Some(1)),
7514                (Some(2), Some(2)),
7515                (Some(3), Some(3)),
7516                (Some(8), Some(3)),
7517                (Some(u8::MAX), Some(3)),
7518            ] {
7519                assert_eq!(
7520                    ruleset_landlock_abi(kernel, tier),
7521                    built,
7522                    "kernel ABI {kernel:?} on {tier:?}"
7523                );
7524            }
7525        }
7526    }
7527
7528    /// No Landlock on the kernel, or a tier that installs no Landlock domain, builds at no ABI.
7529    #[test]
7530    fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
7531        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
7532            assert_eq!(ruleset_landlock_abi(None, tier), None);
7533            assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
7534        }
7535        for tier in [
7536            EnforcementTier::KernelSeccompOnly,
7537            EnforcementTier::EnvironmentOnly,
7538        ] {
7539            for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
7540                assert_eq!(
7541                    ruleset_landlock_abi(kernel, tier),
7542                    None,
7543                    "kernel ABI {kernel:?} on {tier:?}"
7544                );
7545            }
7546        }
7547    }
7548
7549    /// `ShellEnforcement::resolve` carries the ruleset ABI of the probe it is handed, keyed on the
7550    /// applied tier, and an all-negative probe carries none.
7551    #[test]
7552    fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
7553        let negative = unprobed();
7554        for abi in [None, Some(1), Some(8)] {
7555            let enforcement = ShellEnforcement::resolve_declared(
7556                &CapabilityPolicy::default(),
7557                murmur_artifact::ContainmentClass::Advisory,
7558                negative.clone().with_landlock_kernel_abi(abi),
7559            )
7560            .unwrap()
7561            .with_test_session_dirs();
7562            assert_eq!(
7563                enforcement.landlock_abi,
7564                ruleset_landlock_abi(abi, enforcement.tier),
7565                "kernel ABI {abi:?}"
7566            );
7567            // An all-negative probe resolves below the Landlock tiers on every OS.
7568            assert_eq!(enforcement.landlock_abi, None);
7569        }
7570        if cfg!(target_os = "linux") {
7571            let full = HostProbe::from_parts(Some(true), sealed_incapable());
7572            let enforcement = ShellEnforcement::resolve_declared(
7573                &CapabilityPolicy::default(),
7574                murmur_artifact::ContainmentClass::Scoped,
7575                full.with_landlock_kernel_abi(Some(8)),
7576            )
7577            .unwrap()
7578            .with_test_session_dirs();
7579            assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
7580            assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
7581        }
7582    }
7583
7584    /// The `mur doctor` Landlock block for each kernel ABI that changes what it says.
7585    #[test]
7586    fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
7587        if !cfg!(target_os = "linux") {
7588            for abi in [None, Some(1), Some(8)] {
7589                assert_eq!(
7590                    landlock_doctor_lines(abi),
7591                    vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
7592                );
7593            }
7594            return;
7595        }
7596
7597        let none = landlock_doctor_lines(None).join("\n");
7598        assert!(none.contains("no Landlock on this host"), "{none}");
7599        assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
7600
7601        let v1 = landlock_doctor_lines(Some(1)).join("\n");
7602        assert!(v1.contains("kernel ABI: 1"), "{v1}");
7603        assert!(v1.contains("ruleset ABI: 1"), "{v1}");
7604        assert!(v1.contains("EXDEV"), "{v1}");
7605        assert!(v1.contains("Invalid cross-device link"), "{v1}");
7606        assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
7607        assert!(v1.contains("uv"), "{v1}");
7608
7609        let v2 = landlock_doctor_lines(Some(2)).join("\n");
7610        assert!(v2.contains("kernel ABI: 2"), "{v2}");
7611        assert!(v2.contains("ruleset ABI: 2"), "{v2}");
7612        assert!(!v2.contains("EXDEV"), "{v2}");
7613        assert!(
7614            v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
7615            "{v2}"
7616        );
7617
7618        for kernel in [3, 8] {
7619            let lines = landlock_doctor_lines(Some(kernel));
7620            assert_eq!(
7621                lines,
7622                vec![
7623                    format!("kernel ABI: {kernel}"),
7624                    "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
7625                ]
7626            );
7627        }
7628    }
7629
7630    /// The unconfined stance is the unconfined tier on every host, and the tier prints none of
7631    /// the tier warnings: the launch prints `W-SEC-036` instead.
7632    #[test]
7633    fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
7634        use murmur_artifact::ContainmentClass;
7635        for host_tier in [
7636            EnforcementTier::KernelSealed,
7637            EnforcementTier::KernelFull,
7638            EnforcementTier::KernelSeccompOnly,
7639            EnforcementTier::EnvironmentOnly,
7640        ] {
7641            assert_eq!(
7642                applied_tier(host_tier, ContainmentClass::Unconfined),
7643                EnforcementTier::Unconfined,
7644                "{host_tier:?}"
7645            );
7646        }
7647        assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
7648        assert_eq!(
7649            ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
7650            None
7651        );
7652    }
7653
7654    /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
7655    /// could give it more. Silently installing a composed root under a `scoped` declaration would
7656    /// delete host paths its `interpreter_runtime` grants legitimately name.
7657    #[test]
7658    fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
7659        use murmur_artifact::ContainmentClass;
7660
7661        assert_eq!(
7662            applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
7663            EnforcementTier::KernelSealed
7664        );
7665        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
7666            assert_eq!(
7667                applied_tier(EnforcementTier::KernelSealed, declared),
7668                EnforcementTier::KernelFull,
7669                "declaring {declared} must not opt a capsule into the composed root"
7670            );
7671        }
7672
7673        // Every other tier is passed through untouched, whatever was declared — the declared floor
7674        // can only ever weaken the applied mechanism, never strengthen it past the host.
7675        for host_tier in [
7676            EnforcementTier::KernelFull,
7677            EnforcementTier::KernelSeccompOnly,
7678            EnforcementTier::EnvironmentOnly,
7679        ] {
7680            for declared in [
7681                ContainmentClass::Advisory,
7682                ContainmentClass::Scoped,
7683                ContainmentClass::Sealed,
7684            ] {
7685                assert_eq!(applied_tier(host_tier, declared), host_tier);
7686            }
7687        }
7688    }
7689
7690    /// One (artifact, program) of declared needs granting `paths`, each `(path, directory)`, for
7691    /// tests that hand the enforcement needs without deriving them.
7692    pub(super) fn granted_needs(
7693        paths: &[(PathBuf, bool)],
7694    ) -> Vec<crate::declared_needs::ResolvedProgramNeeds> {
7695        vec![crate::declared_needs::ResolvedProgramNeeds {
7696            artifact: "local/needs-tool".to_string(),
7697            program: "cat".to_string(),
7698            granted: paths
7699                .iter()
7700                .map(|(path, directory)| crate::declared_needs::ResolvedNeed {
7701                    need: murmur_artifact::DeclaredNeed {
7702                        form: murmur_artifact::NeedForm::Etc("fixture".to_string()),
7703                        packages: Default::default(),
7704                    },
7705                    paths: vec![crate::declared_needs::NeedPath {
7706                        path: path.clone(),
7707                        directory: *directory,
7708                    }],
7709                })
7710                .collect(),
7711            missing: Vec::new(),
7712        }]
7713    }
7714
7715    /// A declared need is mirrored into the composed root at its own path: a file as that file,
7716    /// never its folder; nothing the fixed trees, the `/etc` allowlist or a bind directory
7717    /// already carry.
7718    #[test]
7719    fn sealed_need_mirrors_are_each_need_at_its_own_path() {
7720        let needs = granted_needs(&[
7721            (PathBuf::from("/etc/fonts/fonts.conf"), false),
7722            (PathBuf::from("/usr/share/fonts"), true),
7723            (PathBuf::from("/lib/x86_64-linux-gnu/libm.so.6"), false),
7724            (PathBuf::from("/etc/ssl/certs"), true),
7725            (PathBuf::from("/opt/fixture/share"), true),
7726            (PathBuf::from("/opt/fixture/share"), true),
7727            (PathBuf::from("/var/cache/fontconfig"), true),
7728        ]);
7729        assert_eq!(
7730            resolve_sealed_need_mirrors(&needs, &[]),
7731            [
7732                PathBuf::from("/etc/fonts/fonts.conf"),
7733                PathBuf::from("/opt/fixture/share"),
7734                PathBuf::from("/var/cache/fontconfig"),
7735            ]
7736        );
7737        assert!(!resolve_sealed_need_mirrors(&needs, &[]).contains(&PathBuf::from("/etc")));
7738        assert_eq!(
7739            resolve_sealed_need_mirrors(&needs, &[PathBuf::from("/opt/fixture")]),
7740            [
7741                PathBuf::from("/etc/fonts/fonts.conf"),
7742                PathBuf::from("/var/cache/fontconfig"),
7743            ]
7744        );
7745    }
7746
7747    /// Every declared-need grant is read-only, and listable only for a directory.
7748    #[test]
7749    fn resolve_declared_need_grants_reads_and_lists_but_never_executes() {
7750        let needs = granted_needs(&[
7751            (PathBuf::from("/opt/fixture/lib/libfix.so.1"), false),
7752            (PathBuf::from("/opt/fixture/share"), true),
7753        ]);
7754        assert_eq!(
7755            resolve_declared_need_grants(&needs),
7756            [
7757                LandlockGrant {
7758                    path: PathBuf::from("/opt/fixture/lib/libfix.so.1"),
7759                    list_dir: false,
7760                    executable: false,
7761                },
7762                LandlockGrant {
7763                    path: PathBuf::from("/opt/fixture/share"),
7764                    list_dir: true,
7765                    executable: false,
7766                },
7767            ]
7768        );
7769        let mut policy = CapabilityPolicy {
7770            shell_allow: vec!["cat".to_string()],
7771            ..CapabilityPolicy::default()
7772        };
7773        policy.shell_declared_needs = needs;
7774        // A manifest's own grant is kept beside the needs: Landlock rules union.
7775        policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
7776            binary: "cat".to_string(),
7777            dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7778                path: "/opt/own-grant".to_string(),
7779                list_dir: false,
7780            }],
7781        }];
7782        let enforcement = ShellEnforcement::resolve_declared(
7783            &policy,
7784            murmur_artifact::ContainmentClass::Scoped,
7785            HostProbe::probe(),
7786        )
7787        .unwrap();
7788        for path in ["/opt/fixture/lib/libfix.so.1", "/opt/fixture/share"] {
7789            let grant = enforcement
7790                .landlock_grants
7791                .iter()
7792                .find(|grant| grant.path == Path::new(path))
7793                .unwrap_or_else(|| panic!("{path} is granted"));
7794            assert!(!grant.executable, "{grant:?}");
7795        }
7796        assert!(enforcement
7797            .landlock_grants
7798            .iter()
7799            .any(|grant| grant.path == Path::new("/opt/own-grant") && grant.executable));
7800        assert_eq!(
7801            enforcement.sealed_need_mirrors,
7802            [
7803                PathBuf::from("/opt/fixture/lib/libfix.so.1"),
7804                PathBuf::from("/opt/fixture/share"),
7805            ]
7806        );
7807    }
7808
7809    /// A `pinned` program choice: its store entry is granted listable and executable (never a
7810    /// write right), the exec allowlist resolves the name through the entry's `bin/` folder to the
7811    /// canonical file, the sealed bind list carries the entry and the host-path check counts it
7812    /// as an execute root. A `host` or `missing` choice adds none of these.
7813    #[test]
7814    fn pinned_programs_grant_list_and_execute_on_the_entry_and_bind_it_only_when_pinned() {
7815        use crate::pinned_programs::{PinnedSource, ResolvedPinnedProgram};
7816        let temp = tempfile::tempdir().unwrap();
7817        let tree = std::fs::canonicalize(temp.path())
7818            .unwrap()
7819            .join("programs/abc");
7820        std::fs::create_dir_all(tree.join("files/fixture")).unwrap();
7821        std::fs::create_dir_all(tree.join("bin")).unwrap();
7822        let file = tree.join("files/fixture/murmur-pinned-fixture-prog");
7823        std::fs::copy("/bin/true", &file).unwrap();
7824        std::os::unix::fs::symlink(
7825            "../files/fixture/murmur-pinned-fixture-prog",
7826            tree.join("bin/murmur-pinned-fixture-prog"),
7827        )
7828        .unwrap();
7829        let chosen = |source: PinnedSource| CapabilityPolicy {
7830            shell_allow: vec!["murmur-pinned-fixture-prog".to_string()],
7831            shell_pinned_programs: vec![ResolvedPinnedProgram {
7832                artifact: "local/pinned-tool".to_string(),
7833                program: "murmur-pinned-fixture-prog".to_string(),
7834                platform: "linux-x86_64".to_string(),
7835                source,
7836            }],
7837            ..CapabilityPolicy::default()
7838        };
7839        let resolve = |policy: &CapabilityPolicy| {
7840            ShellEnforcement::resolve_declared(
7841                policy,
7842                murmur_artifact::ContainmentClass::Scoped,
7843                HostProbe::probe(),
7844            )
7845            .unwrap()
7846        };
7847
7848        let pinned = chosen(PinnedSource::Pinned {
7849            path: file.clone(),
7850            bin_dir: tree.join("bin"),
7851            tree: tree.clone(),
7852            url: "http://127.0.0.1:1/f.zip".to_string(),
7853            sha256: "abc".to_string(),
7854        });
7855        assert_eq!(resolve_policy_exec_allowlist(&pinned), vec![file.clone()]);
7856        assert_eq!(
7857            resolve_pinned_program_landlock_grants(&pinned),
7858            vec![LandlockGrant {
7859                path: tree.clone(),
7860                list_dir: true,
7861                executable: true,
7862            }]
7863        );
7864        let enforcement = resolve(&pinned);
7865        assert!(enforcement.landlock_grants.contains(&LandlockGrant {
7866            path: tree.clone(),
7867            list_dir: true,
7868            executable: true,
7869        }));
7870        assert!(
7871            enforcement
7872                .landlock_grants
7873                .iter()
7874                .any(|grant| grant.path == file && grant.executable),
7875            "the pinned file seeds the DT_NEEDED closure like any shell.allow binary"
7876        );
7877        assert_eq!(enforcement.pinned_program_dirs, vec![tree.clone()]);
7878        assert!(host_path_execute_roots(&pinned)
7879            .iter()
7880            .any(|root| root.dir == tree));
7881
7882        for source in [
7883            PinnedSource::Host {
7884                path: PathBuf::from("/bin/true"),
7885            },
7886            PinnedSource::Missing {
7887                reason: "the store holds no copy".to_string(),
7888            },
7889        ] {
7890            let policy = chosen(source);
7891            assert!(resolve_policy_exec_allowlist(&policy).is_empty());
7892            let enforcement = resolve(&policy);
7893            assert!(enforcement.pinned_program_dirs.is_empty());
7894            assert!(!enforcement
7895                .landlock_grants
7896                .iter()
7897                .any(|grant| grant.path.starts_with(&tree)));
7898            assert!(!host_path_execute_roots(&policy)
7899                .iter()
7900                .any(|root| root.dir.starts_with(&tree)));
7901        }
7902    }
7903
7904    #[test]
7905    fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
7906        use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
7907
7908        let policy = CapabilityPolicy {
7909            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7910                binary: "python3".to_string(),
7911                dirs: vec![
7912                    // Already inside /usr, which the fixed list binds wholesale.
7913                    InterpreterRuntimeDir {
7914                        path: "/usr/lib/python3.11".to_string(),
7915                        list_dir: true,
7916                    },
7917                    InterpreterRuntimeDir {
7918                        path: "/opt/py/lib".to_string(),
7919                        list_dir: true,
7920                    },
7921                    // A duplicate, and a relative path that names nothing absolute.
7922                    InterpreterRuntimeDir {
7923                        path: "/opt/py/lib".to_string(),
7924                        list_dir: false,
7925                    },
7926                    InterpreterRuntimeDir {
7927                        path: "relative/lib".to_string(),
7928                        list_dir: false,
7929                    },
7930                ],
7931            }],
7932            ..CapabilityPolicy::default()
7933        };
7934        let exec_allow = vec![
7935            PathBuf::from("/usr/bin/python3"),
7936            PathBuf::from("/opt/toolchain/bin/cc"),
7937        ];
7938
7939        assert_eq!(
7940            resolve_sealed_bind_dirs(&exec_allow, &policy),
7941            vec![
7942                PathBuf::from("/opt/toolchain/bin"),
7943                PathBuf::from("/opt/py/lib")
7944            ]
7945        );
7946    }
7947
7948    /// The execute roots a host path is checked against include each `interpreter_runtime`
7949    /// directory and each `staged_runtime` tree: a read-write entry inside the first is refused on
7950    /// both Landlock mechanisms, and one containing the second is refused at `scoped` and bound
7951    /// read-only beneath on `sealed`. Pure: none of the paths need exist.
7952    #[test]
7953    fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
7954        use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
7955        let policy = CapabilityPolicy {
7956            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7957                binary: "python3".to_string(),
7958                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7959                    path: "/nonexistent-host-path-test/interp".to_string(),
7960                    list_dir: true,
7961                }],
7962            }],
7963            shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
7964                binary: "python3".to_string(),
7965                source_path: "/nonexistent-host-path-test/opt/py".to_string(),
7966                pin: "pin-1".to_string(),
7967            }],
7968            ..CapabilityPolicy::default()
7969        };
7970        let roots = execute_roots_from(&[], &policy);
7971        let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
7972        assert_eq!(
7973            dirs,
7974            [
7975                Path::new("/nonexistent-host-path-test/interp"),
7976                Path::new("/nonexistent-host-path-test/opt/py"),
7977            ]
7978        );
7979        let read_write = |path: &str| HostPath {
7980            declared: path.to_string(),
7981            path: PathBuf::from(path),
7982            access: HostPathAccess::ReadWrite,
7983            credential_locations: Vec::new(),
7984        };
7985
7986        let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
7987        for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
7988            assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
7989        }
7990
7991        let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
7992        assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
7993        assert_eq!(
7994            check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
7995            [PathBuf::from("/nonexistent-host-path-test/opt/py")]
7996        );
7997    }
7998
7999    /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
8000    /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
8001    /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
8002    /// relative path — which cannot be re-based under the composed root at all — is skipped.
8003    #[test]
8004    fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
8005        use murmur_artifact::StagedRuntimeGrant;
8006
8007        let grant = |binary: &str, source: &str| StagedRuntimeGrant {
8008            binary: binary.to_string(),
8009            source_path: source.to_string(),
8010            pin: "pin-1".to_string(),
8011        };
8012        let policy = CapabilityPolicy {
8013            shell_staged_runtime: vec![
8014                grant("python3", "/opt/py"),
8015                // Inside /usr, which the fixed list already binds. Kept anyway — unlike
8016                // `resolve_sealed_bind_dirs`, which drops exactly this case.
8017                grant("perl", "/usr/lib/perl5"),
8018                // A duplicate of the first, and a relative path naming nothing absolute.
8019                grant("python3.12", "/opt/py"),
8020                grant("ruby", "relative/ruby"),
8021            ],
8022            ..CapabilityPolicy::default()
8023        };
8024
8025        assert_eq!(
8026            resolve_staged_runtime_dirs(&policy),
8027            vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
8028        );
8029    }
8030
8031    /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
8032    /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
8033    /// must therefore cover the same directories.
8034    #[test]
8035    fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
8036        use murmur_artifact::StagedRuntimeGrant;
8037
8038        let policy = CapabilityPolicy {
8039            shell_staged_runtime: vec![StagedRuntimeGrant {
8040                binary: "python3".to_string(),
8041                source_path: "/opt/py".to_string(),
8042                pin: "pin-1".to_string(),
8043            }],
8044            ..CapabilityPolicy::default()
8045        };
8046
8047        let dirs = resolve_staged_runtime_dirs(&policy);
8048        let grants = resolve_staged_runtime_landlock_grants(&policy);
8049
8050        assert_eq!(
8051            grants,
8052            vec![LandlockGrant {
8053                path: PathBuf::from("/opt/py"),
8054                list_dir: true,
8055                executable: true,
8056            }],
8057            "a staged tree is walked by the runtime that uses it, so it must be listable",
8058        );
8059        assert_eq!(
8060            grants
8061                .iter()
8062                .map(|grant| grant.path.clone())
8063                .collect::<Vec<_>>(),
8064            dirs,
8065            "the bound set and the granted set must not drift apart",
8066        );
8067    }
8068
8069    #[test]
8070    fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
8071        let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
8072
8073        assert_eq!(
8074            grants,
8075            crate::sealed::SEALED_RUNTIME_PATHS
8076                .iter()
8077                .map(|path| LandlockGrant {
8078                    path: PathBuf::from(path),
8079                    list_dir: true,
8080                    executable: false,
8081                })
8082                .collect::<Vec<_>>(),
8083            "the composed root binds exactly these, so exactly these must be enumerable — \
8084             the bound set and the granted set must not drift apart",
8085        );
8086        assert!(
8087            grants.iter().all(|grant| grant.list_dir),
8088            "a runtime tree an interpreter walks is useless without getdents64",
8089        );
8090        assert!(
8091            grants.iter().all(|grant| !grant.executable),
8092            "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
8093             sealed capsule run every binary the host ships, whatever shell.allow says",
8094        );
8095    }
8096
8097    #[test]
8098    fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
8099        for tier in [
8100            EnforcementTier::KernelFull,
8101            EnforcementTier::KernelSeccompOnly,
8102            EnforcementTier::EnvironmentOnly,
8103        ] {
8104            assert!(
8105                resolve_sealed_runtime_landlock_grants(tier).is_empty(),
8106                "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
8107                 would enumerate the real host filesystem",
8108            );
8109        }
8110    }
8111
8112    /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
8113    /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
8114    /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
8115    /// is the only honest thing a test can do about a kernel capability it may not have.
8116    #[test]
8117    fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
8118        let policy = CapabilityPolicy::default();
8119        let usr = PathBuf::from("/usr");
8120
8121        let scoped = ShellEnforcement::resolve_declared(
8122            &policy,
8123            murmur_artifact::ContainmentClass::Scoped,
8124            HostProbe::probe(),
8125        )
8126        .expect("an empty policy resolves")
8127        .with_test_session_dirs();
8128        assert!(
8129            !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
8130            "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
8131             unenumerable there",
8132        );
8133
8134        let sealed = ShellEnforcement::resolve_declared(
8135            &policy,
8136            murmur_artifact::ContainmentClass::Sealed,
8137            HostProbe::probe(),
8138        )
8139        .expect("an empty policy resolves")
8140        .with_test_session_dirs();
8141        let granted: Vec<&LandlockGrant> = sealed
8142            .landlock_grants
8143            .iter()
8144            .filter(|grant| {
8145                crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
8146            })
8147            .collect();
8148        if sealed.tier == EnforcementTier::KernelSealed {
8149            assert_eq!(
8150                granted.len(),
8151                crate::sealed::SEALED_RUNTIME_PATHS.len(),
8152                "a sealed session must carry one listable grant per bound runtime path",
8153            );
8154            assert!(granted
8155                .iter()
8156                .all(|grant| grant.list_dir && !grant.executable));
8157        } else {
8158            assert!(
8159                granted.is_empty(),
8160                "this host fell back to {:?}, where there is no composed root to enumerate",
8161                sealed.tier,
8162            );
8163        }
8164    }
8165
8166    #[test]
8167    fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
8168        let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
8169
8170        assert_eq!(
8171            grants,
8172            crate::sealed::SEALED_ETC_PATHS
8173                .iter()
8174                .map(|entry| LandlockGrant {
8175                    path: PathBuf::from(entry.path),
8176                    list_dir: entry.directory,
8177                    executable: false,
8178                })
8179                .collect::<Vec<_>>(),
8180            "the composed root binds exactly these /etc entries, so exactly these must be \
8181             readable — the bound set and the granted set must not drift apart",
8182        );
8183        assert!(
8184            grants.iter().all(|grant| !grant.executable),
8185            "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
8186             run — /etc/alternatives is a directory of symlinks into /usr/bin",
8187        );
8188
8189        // The `directory` classification itself, spelled out independently of the constant so a
8190        // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
8191        // regular file) shows up as a test failure rather than as a TLS bug on a real host.
8192        let listable: Vec<&str> = grants
8193            .iter()
8194            .filter(|grant| grant.list_dir)
8195            .map(|grant| grant.path.to_str().unwrap())
8196            .collect();
8197        assert_eq!(
8198            listable,
8199            vec![
8200                "/etc/ld.so.conf.d",
8201                "/etc/alternatives",
8202                "/etc/ssl",
8203                "/etc/pki",
8204                "/etc/ca-certificates",
8205                "/etc/terminfo",
8206            ],
8207            "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
8208             the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
8209        );
8210    }
8211
8212    #[test]
8213    fn sealed_etc_landlock_grants_are_sealed_tier_only() {
8214        for tier in [
8215            EnforcementTier::KernelFull,
8216            EnforcementTier::KernelSeccompOnly,
8217            EnforcementTier::EnvironmentOnly,
8218        ] {
8219            assert!(
8220                resolve_sealed_etc_landlock_grants(tier).is_empty(),
8221                "{tier:?} has no composed root, so these rules would hand out the real host's \
8222                 /etc — its trust store, resolv.conf and account databases",
8223            );
8224        }
8225    }
8226
8227    /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
8228    /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
8229    /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
8230    /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
8231    #[test]
8232    fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
8233        let policy = CapabilityPolicy::default();
8234        let is_etc_grant = |grant: &LandlockGrant| {
8235            crate::sealed::SEALED_ETC_PATHS
8236                .iter()
8237                .any(|entry| Path::new(entry.path) == grant.path)
8238        };
8239
8240        let scoped = ShellEnforcement::resolve_declared(
8241            &policy,
8242            murmur_artifact::ContainmentClass::Scoped,
8243            HostProbe::probe(),
8244        )
8245        .expect("an empty policy resolves")
8246        .with_test_session_dirs();
8247        // The two name-resolution files are the one exception, granted read-only by
8248        // `resolve_scoped_name_resolution_landlock_grants`; nothing else of the list is.
8249        let name_resolution = |grant: &LandlockGrant| {
8250            SCOPED_NAME_RESOLUTION_PATHS
8251                .iter()
8252                .any(|path| Path::new(path) == grant.path)
8253        };
8254        assert!(
8255            !scoped
8256                .landlock_grants
8257                .iter()
8258                .any(|grant| is_etc_grant(grant) && !name_resolution(grant)),
8259            "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
8260             neighbours must not become readable there",
8261        );
8262        assert_eq!(
8263            scoped
8264                .landlock_grants
8265                .iter()
8266                .filter(|grant| name_resolution(grant))
8267                .count(),
8268            SCOPED_NAME_RESOLUTION_PATHS.len()
8269        );
8270
8271        let sealed = ShellEnforcement::resolve_declared(
8272            &policy,
8273            murmur_artifact::ContainmentClass::Sealed,
8274            HostProbe::probe(),
8275        )
8276        .expect("an empty policy resolves")
8277        .with_test_session_dirs();
8278        let granted: Vec<&LandlockGrant> = sealed
8279            .landlock_grants
8280            .iter()
8281            .filter(|grant| is_etc_grant(grant))
8282            .collect();
8283        if sealed.tier == EnforcementTier::KernelSealed {
8284            assert_eq!(
8285                granted.len(),
8286                crate::sealed::SEALED_ETC_PATHS.len(),
8287                "a sealed session must carry one read grant per bound /etc entry",
8288            );
8289            assert!(granted.iter().all(|grant| !grant.executable));
8290        } else {
8291            // A fallback to `scoped` grants what `scoped` grants: the name-resolution files and
8292            // nothing else of the host's own `/etc`.
8293            assert!(
8294                granted
8295                    .iter()
8296                    .all(|grant| name_resolution(grant) && !grant.executable && !grant.list_dir),
8297                "this host fell back to {:?}, where there is no composed root and /etc is the \
8298                 host's own: {granted:?}",
8299                sealed.tier,
8300            );
8301            assert_eq!(
8302                granted.len(),
8303                resolve_scoped_name_resolution_landlock_grants(sealed.tier).len(),
8304                "{:?}: {granted:?}",
8305                sealed.tier,
8306            );
8307        }
8308    }
8309
8310    #[test]
8311    fn resolve_network_allowlist_ips_empty_list_is_empty() {
8312        let ips = resolve_network_allowlist_ips(&[]).unwrap();
8313        assert!(ips.is_empty());
8314    }
8315
8316    #[test]
8317    fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
8318        let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
8319        assert!(
8320            !ips.is_empty(),
8321            "localhost should resolve to at least one address"
8322        );
8323        assert!(
8324            ips.iter().all(|ip| ip.is_loopback()),
8325            "every resolved localhost address should be loopback, got {ips:?}"
8326        );
8327    }
8328
8329    #[test]
8330    fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
8331        let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
8332        assert!(!error.is_empty());
8333    }
8334
8335    /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
8336    /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
8337    /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
8338    /// to resolve, which keeps this test independent of the host's DNS.
8339    #[test]
8340    fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
8341        let ips =
8342            resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
8343                .expect("an unresolvable host must not be a hard error");
8344        assert!(
8345            ips.is_empty(),
8346            "an unresolvable host should contribute no IPs, got {ips:?}"
8347        );
8348    }
8349
8350    /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
8351    /// loop continues rather than aborting on the first miss.
8352    #[test]
8353    fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
8354        let ips = resolve_network_allowlist_ips(&[
8355            "definitely-not-a-real-host.invalid".to_string(),
8356            "localhost".to_string(),
8357        ])
8358        .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
8359
8360        assert!(
8361            !ips.is_empty(),
8362            "localhost's addresses should survive alongside an unresolvable host"
8363        );
8364        assert!(
8365            ips.iter().all(|ip| ip.is_loopback()),
8366            "only localhost should have contributed addresses, got {ips:?}"
8367        );
8368    }
8369
8370    #[test]
8371    fn resolve_network_allowlist_ips_dedupes_across_hosts() {
8372        // Two entries that both resolve to loopback should not produce duplicate IPs beyond
8373        // what's actually distinct.
8374        let ips =
8375            resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
8376                .unwrap();
8377        let mut sorted = ips.clone();
8378        sorted.sort();
8379        sorted.dedup();
8380        assert_eq!(
8381            ips.len(),
8382            sorted.len(),
8383            "resolved IPs should already be deduplicated"
8384        );
8385    }
8386
8387    #[test]
8388    fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
8389        let enforcement = ShellEnforcement::environment_only();
8390        assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
8391        assert!(enforcement.network_allow_ips.is_empty());
8392    }
8393
8394    /// With no scope a spawn's `RLIMIT_NPROC` is headroom over a real count. A `0` baseline
8395    /// would turn `max_processes` into an absolute per-uid ceiling that an interactive account
8396    /// is already past, failing the subprocess's first `fork()`.
8397    #[cfg(any(target_os = "linux", target_os = "macos"))]
8398    #[test]
8399    fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
8400        match ShellEnforcement::environment_only()
8401            .with_test_session_dirs()
8402            .nproc_bound()
8403        {
8404            crate::resources::NprocBound::Headroom { baseline } => assert!(
8405                baseline > 0,
8406                "the test process is owned by this uid, so the count cannot be zero"
8407            ),
8408            other => panic!("no scope must give headroom, got {other:?}"),
8409        }
8410    }
8411
8412    /// The baseline is taken on each call, not when the enforcement was built: threads the host
8413    /// starts in between are counted by the next spawn.
8414    #[cfg(target_os = "linux")]
8415    #[test]
8416    fn nproc_bound_without_a_scope_is_measured_on_each_call() {
8417        let _lock = crate::resources::test_support::host_threads_lock();
8418        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
8419        let baseline = |bound| match bound {
8420            crate::resources::NprocBound::Headroom { baseline } => baseline,
8421            other => panic!("no scope must give headroom, got {other:?}"),
8422        };
8423
8424        let first = baseline(enforcement.nproc_bound());
8425        let holders = crate::resources::test_support::HeldThreads::start(200);
8426        let second = baseline(enforcement.nproc_bound());
8427        drop(holders);
8428
8429        assert!(
8430            second > first,
8431            "200 threads started between the calls must raise the baseline: {first} then {second}"
8432        );
8433    }
8434
8435    /// A spawn that will join a live scope leaves `RLIMIT_NPROC` to `pids.max`.
8436    #[cfg(target_os = "linux")]
8437    #[test]
8438    fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
8439        if !crate::cgroup::cgroup_delegation_available() {
8440            crate::runtime_err!(
8441                "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
8442                 cannot delegate a cgroup v2 scope"
8443            );
8444            return;
8445        }
8446        let temp = tempfile::tempdir().unwrap();
8447        let limits = crate::resources::HostResourceLimits::default();
8448        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
8449            .expect("this host delegates a cgroup scope")
8450            .scope;
8451        let enforcement = ShellEnforcement::environment_only()
8452            .with_test_session_dirs()
8453            .with_host_bounding(scope, None);
8454
8455        assert_eq!(
8456            enforcement.nproc_bound(),
8457            crate::resources::NprocBound::CgroupPidsMax
8458        );
8459    }
8460
8461    /// A Linux capsule with a cgroup scope gets no `RLIMIT_NPROC` from the runtime, so threads the
8462    /// rest of the host starts after the enforcement was resolved cannot spend its fork headroom.
8463    /// The 400 holders put the uid well past any `RLIMIT_NPROC` derived from a count taken before
8464    /// they started, which fails the first `fork()` in the subprocess with `EAGAIN`. The 40
8465    /// concurrent `sleep`s and the concurrent `node` children each need headroom well past the
8466    /// declared `max_processes` of 32.
8467    #[cfg(target_os = "linux")]
8468    #[test]
8469    fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
8470        const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
8471        if !crate::cgroup::cgroup_delegation_available() {
8472            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
8473            return;
8474        }
8475        let _lock = crate::resources::test_support::host_threads_lock();
8476
8477        let temp = tempfile::tempdir().unwrap();
8478        let limits = crate::resources::HostResourceLimits {
8479            max_processes: 32,
8480            max_open_files: 256,
8481            ..crate::resources::HostResourceLimits::default()
8482        };
8483        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
8484            .expect("this host delegates a cgroup scope")
8485            .scope;
8486        assert!(
8487            scope.is_some(),
8488            "a required scope on Linux is always created"
8489        );
8490        let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
8491        let mut shell_allow = vec!["bash".to_string()];
8492        if node_available {
8493            shell_allow.push("node".to_string());
8494        }
8495        let policy = CapabilityPolicy {
8496            shell_allow,
8497            resources: limits,
8498            ..CapabilityPolicy::default()
8499        };
8500        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
8501        enforcement.resource_limits = limits;
8502        let enforcement = enforcement.with_host_bounding(scope, None);
8503
8504        let holders = crate::resources::test_support::HeldThreads::start(400);
8505
8506        let result = crate::shell::execute_shell(
8507            "bash",
8508            &[
8509                "-c",
8510                "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
8511            ],
8512            &[],
8513            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8514            &policy,
8515            &enforcement,
8516        )
8517        .expect("bash must run");
8518        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
8519        assert_eq!(result.resource_limit_hit, None);
8520        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
8521        let lines: Vec<&str> = result.stdout.lines().collect();
8522        assert_eq!(
8523            lines,
8524            [
8525                crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
8526                "256"
8527            ],
8528            "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
8529        );
8530
8531        if node_available {
8532            let script = "const { spawn } = require('child_process');\n\
8533                const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
8534                  const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
8535                  child.once('error', (error) => resolve(error.message));\n\
8536                  child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
8537                }));\n\
8538                Promise.all(runs).then((outcomes) => {\n\
8539                  const failures = outcomes.filter((outcome) => outcome !== null);\n\
8540                  if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
8541                });\n";
8542            let result = crate::shell::execute_shell(
8543                "node",
8544                &["-e", script],
8545                &[],
8546                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8547                &policy,
8548                &enforcement,
8549            )
8550            .expect("node must run");
8551            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
8552        } else {
8553            crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
8554        }
8555
8556        drop(holders);
8557    }
8558
8559    /// Without a scope `RLIMIT_NPROC` is the only process bound, so its baseline has to follow the
8560    /// host: 300 threads started after the enforcement was resolved raise the next spawn's
8561    /// ceiling, and releasing them lowers the one after.
8562    #[cfg(target_os = "linux")]
8563    #[test]
8564    fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
8565        const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
8566        let _lock = crate::resources::test_support::host_threads_lock();
8567
8568        let temp = tempfile::tempdir().unwrap();
8569        let policy = CapabilityPolicy {
8570            shell_allow: vec!["bash".to_string()],
8571            resources: crate::resources::HostResourceLimits {
8572                max_processes: 64,
8573                ..crate::resources::HostResourceLimits::default()
8574            },
8575            ..CapabilityPolicy::default()
8576        };
8577        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
8578        enforcement.resource_limits = policy.resources;
8579
8580        let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
8581        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
8582        if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
8583            crate::runtime_err!(
8584                "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
8585                 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
8586            );
8587            return;
8588        }
8589
8590        let hard_nproc = || -> u64 {
8591            let result = crate::shell::execute_shell(
8592                "bash",
8593                &["-c", "ulimit -Hu"],
8594                &[],
8595                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8596                &policy,
8597                &enforcement,
8598            )
8599            .expect("bash must run");
8600            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
8601            result.stdout.trim().parse().expect("a finite ceiling")
8602        };
8603
8604        let holders = crate::resources::test_support::HeldThreads::start(300);
8605        let held = hard_nproc();
8606        assert!(
8607            held >= before + 64 + 150,
8608            "the ceiling must count the holder threads started after the enforcement was \
8609             resolved: {held} with {before} tasks before them"
8610        );
8611
8612        drop(holders);
8613        let released = hard_nproc();
8614        assert!(
8615            released < held,
8616            "the ceiling must drop once the holders exit: {released} after, {held} while held"
8617        );
8618    }
8619
8620    fn make_executable(path: &Path, contents: &str) {
8621        use std::os::unix::fs::PermissionsExt;
8622        std::fs::write(path, contents).unwrap();
8623        let mut perms = std::fs::metadata(path).unwrap().permissions();
8624        perms.set_mode(0o755);
8625        std::fs::set_permissions(path, perms).unwrap();
8626    }
8627
8628    fn bootstrap_log_contents(workdir: &Path) -> String {
8629        std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
8630    }
8631
8632    // ---- exec allowlist resolution (identity, not names) ----
8633
8634    #[test]
8635    fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
8636        let temp = tempfile::tempdir().unwrap();
8637        let bin_dir = temp.path().join("bin");
8638        std::fs::create_dir(&bin_dir).unwrap();
8639        let tool = bin_dir.join("mytool");
8640        make_executable(&tool, "#!/bin/sh\nexit 0\n");
8641
8642        let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
8643        assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
8644    }
8645
8646    #[test]
8647    fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
8648        let temp = tempfile::tempdir().unwrap();
8649        let resolved = resolve_exec_allowlist_in(
8650            &["definitely-not-a-real-binary".to_string(), String::new()],
8651            &[temp.path().to_path_buf()],
8652        );
8653        assert!(
8654            resolved.is_empty(),
8655            "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
8656        );
8657    }
8658
8659    #[test]
8660    fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
8661        let temp = tempfile::tempdir().unwrap();
8662        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
8663        let resolved =
8664            resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
8665        assert!(resolved.is_empty());
8666    }
8667
8668    #[test]
8669    fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
8670        let temp = tempfile::tempdir().unwrap();
8671        let real = temp.path().join("real-tool");
8672        make_executable(&real, "#!/bin/sh\nexit 0\n");
8673        let bin_dir = temp.path().join("bin");
8674        std::fs::create_dir(&bin_dir).unwrap();
8675        std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
8676
8677        let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
8678        assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
8679    }
8680
8681    // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
8682
8683    #[test]
8684    fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
8685        let temp = tempfile::tempdir().unwrap();
8686        let bin_dir = temp.path().join("bin");
8687        std::fs::create_dir(&bin_dir).unwrap();
8688        let tool = bin_dir.join("mytool");
8689        make_executable(&tool, "#!/bin/sh\nexit 0\n");
8690
8691        let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
8692        assert_eq!(
8693            resolved,
8694            std::fs::canonicalize(&tool)
8695                .unwrap()
8696                .to_string_lossy()
8697                .into_owned(),
8698            "a resolvable name must report the canonical absolute path, not the bare name"
8699        );
8700    }
8701
8702    #[test]
8703    fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
8704        let temp = tempfile::tempdir().unwrap();
8705        let path_dirs = [temp.path().to_path_buf()];
8706        let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
8707        assert_eq!(
8708            resolved, "definitely-not-a-real-binary",
8709            "an unresolvable name must fall back to the bare name, never error"
8710        );
8711        assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
8712    }
8713
8714    #[test]
8715    fn resolve_invoked_binary_path_takes_the_first_path_match() {
8716        let temp = tempfile::tempdir().unwrap();
8717        let first = temp.path().join("first");
8718        let second = temp.path().join("second");
8719        std::fs::create_dir(&first).unwrap();
8720        std::fs::create_dir(&second).unwrap();
8721        make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
8722        make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
8723
8724        let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
8725        assert_eq!(
8726            resolved,
8727            std::fs::canonicalize(first.join("mytool"))
8728                .unwrap()
8729                .to_string_lossy()
8730                .into_owned(),
8731            "reporting must match execvp: the first PATH hit is the one that ran"
8732        );
8733    }
8734
8735    #[test]
8736    fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
8737        let temp = tempfile::tempdir().unwrap();
8738        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
8739        assert_eq!(
8740            resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
8741            "mytool"
8742        );
8743    }
8744
8745    #[test]
8746    fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
8747        let temp = tempfile::tempdir().unwrap();
8748        let real = temp.path().join("real-tool");
8749        make_executable(&real, "#!/bin/sh\nexit 0\n");
8750        let link = temp.path().join("link-tool");
8751        std::os::unix::fs::symlink(&real, &link).unwrap();
8752
8753        assert_eq!(
8754            resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
8755            std::fs::canonicalize(&real)
8756                .unwrap()
8757                .to_string_lossy()
8758                .into_owned()
8759        );
8760    }
8761
8762    // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
8763
8764    #[test]
8765    fn parse_elf_extracts_interp_needed_and_runpath() {
8766        let bytes = build_elf64(
8767            Some("/lib64/ld-linux-x86-64.so.2"),
8768            &["libc.so.6", "libm.so.6"],
8769            Some("$ORIGIN/../lib:/opt/custom/lib"),
8770        );
8771        let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
8772        assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
8773        assert_eq!(
8774            deps.needed,
8775            vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
8776        );
8777        assert_eq!(
8778            deps.runpaths,
8779            vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
8780            "DT_RUNPATH must be split on ':' with empty parts dropped"
8781        );
8782    }
8783
8784    #[test]
8785    fn parse_elf_static_binary_contributes_only_itself() {
8786        // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
8787        let bytes = build_elf64(None, &[], None);
8788        let deps =
8789            parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
8790        assert_eq!(deps, ElfDependencies::default());
8791    }
8792
8793    #[test]
8794    fn parse_elf_rejects_non_elf_and_non_elf64() {
8795        assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
8796        // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
8797        let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
8798        elf32[4] = 1;
8799        assert!(
8800            parse_elf_dependencies(elf32.as_slice()).is_none(),
8801            "32-bit ELF is not a binary this runtime execs and must not parse"
8802        );
8803    }
8804
8805    /// Every ELF image of this host's `python3`, `perl`, `bash` and `git` closures and every file
8806    /// in python's `lib-dynload`, and truncated and malformed copies of each binary, parse the same
8807    /// read from memory and read by position from the file, `None` included.
8808    #[cfg(target_os = "linux")]
8809    #[test]
8810    #[allow(clippy::print_stdout)]
8811    fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
8812        let mut binaries = Vec::new();
8813        for name in ["python3", "perl", "bash", "git"] {
8814            match find_on_path(name) {
8815                Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
8816                None => println!("[SKIP] no {name} on PATH"),
8817            }
8818        }
8819        let mut images: std::collections::BTreeSet<PathBuf> =
8820            resolve_landlock_grants(&binaries).into_iter().collect();
8821        let dynload = std::process::Command::new("python3")
8822            .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
8823            .output()
8824            .ok()
8825            .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
8826            .unwrap_or_default();
8827        for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
8828            for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
8829                images.insert(entry.path());
8830            }
8831        }
8832        let compare = |path: &Path| {
8833            let Ok(bytes) = std::fs::read(path) else {
8834                return;
8835            };
8836            let file = ElfFile::open_host(path).unwrap();
8837            assert_eq!(
8838                parse_elf_dependencies(bytes.as_slice()),
8839                parse_elf_dependencies(&file),
8840                "{}",
8841                path.display()
8842            );
8843            assert_eq!(
8844                elf_dynamic_symbol_names(bytes.as_slice()),
8845                elf_dynamic_symbol_names(&file),
8846                "{}",
8847                path.display()
8848            );
8849            assert_eq!(
8850                elf_is_shared_object(bytes.as_slice()),
8851                elf_is_shared_object(&file),
8852                "{}",
8853                path.display()
8854            );
8855        };
8856        for path in &images {
8857            compare(path);
8858        }
8859        println!("compared {} host images", images.len());
8860
8861        // Truncated and malformed copies: cut inside each table the parsers read, and with the
8862        // header's table offsets pointed past the end of the file.
8863        let temp = tempfile::tempdir().unwrap();
8864        let mut variants = 0;
8865        for binary in &binaries {
8866            let bytes = std::fs::read(binary).unwrap();
8867            let mut cuts = vec![
8868                0,
8869                1,
8870                17,
8871                18,
8872                63,
8873                64,
8874                100,
8875                4096,
8876                bytes.len() / 2,
8877                bytes.len() - 1,
8878            ];
8879            for field in [32usize, 40] {
8880                if let Some(offset) = read_u64(&bytes, field, true) {
8881                    cuts.push(offset as usize + 30);
8882                }
8883            }
8884            let mut copies: Vec<Vec<u8>> = cuts
8885                .into_iter()
8886                .filter(|cut| *cut <= bytes.len())
8887                .map(|cut| bytes[..cut].to_vec())
8888                .collect();
8889            for field in [32usize, 40] {
8890                let mut moved = bytes.clone();
8891                moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
8892                copies.push(moved);
8893            }
8894            let mut big_endian = bytes.clone();
8895            big_endian[5] = 2;
8896            copies.push(big_endian);
8897            for copy in copies {
8898                let path = temp.path().join(format!("variant-{variants}"));
8899                std::fs::write(&path, &copy).unwrap();
8900                compare(&path);
8901                variants += 1;
8902            }
8903        }
8904        println!("compared {variants} malformed images");
8905    }
8906
8907    /// Resolving a binary's closure reads its headers and tables, not the whole file: a stand-in
8908    /// padded to 64 MiB costs a few KiB.
8909    #[cfg(target_os = "linux")]
8910    #[test]
8911    #[allow(clippy::print_stdout)]
8912    fn resolving_a_closure_reads_no_binary_in_full() {
8913        let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
8914        let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
8915            return;
8916        };
8917        crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
8918        let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
8919        let before = crate::interpreter_runtime::tests::thread_rchar();
8920        let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
8921        let read = crate::interpreter_runtime::tests::thread_rchar() - before;
8922        assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
8923        assert!(grants.contains(&stand_in));
8924        assert!(
8925            read < 8 * 1024 * 1024,
8926            "resolving the closure read {read} bytes"
8927        );
8928        println!("resolving the padded stand-in's closure read {read} bytes");
8929    }
8930
8931    // ---- soname resolution (loader search-order, synthetic fixtures) ----
8932
8933    #[test]
8934    fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
8935        let temp = tempfile::tempdir().unwrap();
8936        let origin = temp.path().join("app");
8937        std::fs::create_dir(&origin).unwrap();
8938        let libs = temp.path().join("libs");
8939        std::fs::create_dir(&libs).unwrap();
8940        std::fs::write(libs.join("libc.so.6"), "x").unwrap();
8941
8942        // Found in a standard search directory.
8943        assert_eq!(
8944            resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
8945            Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
8946        );
8947        // Unresolvable soname → None (shrink-not-fail).
8948        assert_eq!(
8949            resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
8950            None
8951        );
8952
8953        // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
8954        let origin_lib = origin.join("lib");
8955        std::fs::create_dir(&origin_lib).unwrap();
8956        std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
8957        assert_eq!(
8958            resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
8959            Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
8960        );
8961    }
8962
8963    // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
8964
8965    #[test]
8966    fn resolve_landlock_grants_derives_interp_and_needed_closure() {
8967        let temp = tempfile::tempdir().unwrap();
8968        let bin_dir = temp.path().join("bin");
8969        let lib_dir = temp.path().join("lib");
8970        std::fs::create_dir(&bin_dir).unwrap();
8971        std::fs::create_dir(&lib_dir).unwrap();
8972
8973        // A fake interpreter and a fake shared library, both outside any "workdir".
8974        let interp = bin_dir.join("ld-fake.so");
8975        std::fs::write(&interp, "not really elf").unwrap();
8976        std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
8977
8978        // A synthetic dynamically-linked binary naming that interp + that soname.
8979        let prog = bin_dir.join("prog");
8980        let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
8981        std::fs::write(&prog, &elf).unwrap();
8982
8983        let grants =
8984            resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
8985
8986        let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
8987        assert!(
8988            grants.contains(&expect(prog)),
8989            "the binary itself must be granted: {grants:?}"
8990        );
8991        assert!(
8992            grants.contains(&expect(interp)),
8993            "the ELF interpreter must be granted: {grants:?}"
8994        );
8995        assert!(
8996            grants.contains(&expect(lib_dir.join("libfake.so.1"))),
8997            "each resolved DT_NEEDED library must be granted: {grants:?}"
8998        );
8999    }
9000
9001    #[test]
9002    fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
9003        let temp = tempfile::tempdir().unwrap();
9004        let bin_dir = temp.path().join("bin");
9005        std::fs::create_dir(&bin_dir).unwrap();
9006
9007        // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
9008        let prog = bin_dir.join("prog");
9009        std::fs::write(
9010            &prog,
9011            build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
9012        )
9013        .unwrap();
9014        let missing = bin_dir.join("does-not-exist");
9015
9016        let grants =
9017            resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
9018
9019        // Only the real binary survives; the missing binary, missing interp, and unresolvable
9020        // soname each contribute nothing (shrink-not-fail).
9021        assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
9022    }
9023
9024    // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
9025
9026    fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
9027        InterpreterRuntimeGrant {
9028            binary: binary.to_string(),
9029            dirs: dirs
9030                .iter()
9031                .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
9032                    path: path.to_string(),
9033                    list_dir: *list_dir,
9034                })
9035                .collect(),
9036        }
9037    }
9038
9039    #[test]
9040    fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
9041        // Two directories under the same binary, opposite `list_dir` — proving enumerability is
9042        // per-directory and copied exactly, not inferred or unified.
9043        let grants = resolve_interpreter_runtime_grants(&[interp_grant(
9044            "python3",
9045            &[
9046                ("/usr/lib/python3.11", true),
9047                ("/usr/lib/python3.11/lib-dynload", false),
9048            ],
9049        )]);
9050
9051        assert_eq!(
9052            grants,
9053            vec![
9054                LandlockGrant {
9055                    path: PathBuf::from("/usr/lib/python3.11"),
9056                    list_dir: true,
9057                    executable: true,
9058                },
9059                LandlockGrant {
9060                    path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
9061                    list_dir: false,
9062                    executable: true,
9063                },
9064            ]
9065        );
9066    }
9067
9068    #[test]
9069    fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
9070        // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
9071        // scope` skips any that fail to open), and multiple grants flatten in order.
9072        let grants = resolve_interpreter_runtime_grants(&[
9073            interp_grant("python3", &[("/opt/py/nonexistent", false)]),
9074            interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
9075        ]);
9076
9077        assert_eq!(
9078            grants,
9079            vec![
9080                LandlockGrant {
9081                    path: PathBuf::from("/opt/py/nonexistent"),
9082                    list_dir: false,
9083                    executable: true,
9084                },
9085                LandlockGrant {
9086                    path: PathBuf::from("/opt/rb/also-nonexistent"),
9087                    list_dir: true,
9088                    executable: true,
9089                },
9090            ]
9091        );
9092        assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
9093    }
9094
9095    #[test]
9096    fn non_listable_files_marks_every_closure_file_non_enumerable() {
9097        // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
9098        // (ReadDir on a regular file is a no-op anyway).
9099        let wrapped = LandlockGrant::non_listable_files(vec![
9100            PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
9101            PathBuf::from("/usr/bin/bash"),
9102        ]);
9103        assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
9104        assert_eq!(
9105            wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
9106            vec![
9107                PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
9108                PathBuf::from("/usr/bin/bash"),
9109            ]
9110        );
9111    }
9112
9113    // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
9114    //
9115    // The canonical statement of what the *content* checks in this file are worth, referenced by
9116    // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
9117    // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
9118    // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
9119    // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
9120    // buys one thing: changing a constant cannot happen by accident.
9121    //
9122    // The enforcement claim for this constant is verified only by the manual procedure in
9123    // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
9124    // uncontainerized Linux hardware.
9125
9126    #[test]
9127    fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
9128        let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
9129            .iter()
9130            .map(|device| (device.path, device.writable))
9131            .collect();
9132        assert_eq!(
9133            listed,
9134            vec![
9135                ("/dev/null", true),
9136                ("/dev/zero", false),
9137                ("/dev/urandom", false),
9138            ],
9139            "the capsule device set is fixed at exactly these three paths and these access \
9140             levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
9141             and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
9142             both. Widening this list is allowed only on demonstrated workload failure, and the \
9143             failure gets recorded alongside the widening (see the manual acceptance procedure on \
9144             the diagnostics reference page)."
9145        );
9146    }
9147
9148    #[test]
9149    fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
9150        // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
9151        // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
9152        // shell child has dropped). It is excluded because nothing needs a blocking RNG when
9153        // /dev/urandom is granted — not because writing to it would poison the kernel pool.
9154        for excluded in [
9155            "/dev/random",
9156            "/dev/full",
9157            "/dev/tty",
9158            "/dev/console",
9159            "/dev/mem",
9160            "/dev/sda",
9161            "/dev",
9162        ] {
9163            assert!(
9164                !CAPSULE_DEVICE_GRANTS
9165                    .iter()
9166                    .any(|device| device.path == excluded),
9167                "{excluded} must not be in the capsule device set — it is denied by the ordinary \
9168                 no-matching-rule path, and adding it here would be the only way to change that"
9169            );
9170        }
9171    }
9172
9173    #[test]
9174    fn capsule_device_grants_name_absolute_paths_under_dev() {
9175        // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
9176        // against whatever cwd the host process happens to have.
9177        for device in CAPSULE_DEVICE_GRANTS {
9178            assert!(
9179                device.path.starts_with("/dev/"),
9180                "{} must be an absolute path under /dev",
9181                device.path
9182            );
9183        }
9184    }
9185
9186    // ---- `denied_socket_domains` ----------------------------------------------------------
9187    //
9188    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
9189    // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
9190    // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
9191    // real Linux hardware.
9192
9193    #[test]
9194    fn denied_socket_domains_denies_unix_by_default() {
9195        let denied = denied_socket_domains(false);
9196        assert!(
9197            denied.contains(&LINUX_AF_UNIX),
9198            "AF_UNIX must be denied when the manifest does not declare \
9199             capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
9200        );
9201    }
9202
9203    #[test]
9204    fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
9205        let denied = denied_socket_domains(true);
9206        assert!(
9207            !denied.contains(&LINUX_AF_UNIX),
9208            "a capsule that declared capabilities.network.unix_sockets: true must get no \
9209             AF_UNIX deny rule at all"
9210        );
9211    }
9212
9213    /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
9214    /// identical for both values of the one flag that exists.
9215    #[test]
9216    fn denied_socket_domains_always_denies_netlink_and_packet() {
9217        for unix_sockets_allowed in [false, true] {
9218            let denied = denied_socket_domains(unix_sockets_allowed);
9219            assert!(
9220                denied.contains(&LINUX_AF_NETLINK),
9221                "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
9222                 {unix_sockets_allowed})"
9223            );
9224            assert!(
9225                denied.contains(&LINUX_AF_PACKET),
9226                "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
9227                 {unix_sockets_allowed})"
9228            );
9229        }
9230    }
9231
9232    /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
9233    /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
9234    /// deny.
9235    #[test]
9236    fn denied_socket_domains_never_denies_ip_families() {
9237        for unix_sockets_allowed in [false, true] {
9238            let denied = denied_socket_domains(unix_sockets_allowed);
9239            assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
9240            assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
9241        }
9242    }
9243
9244    /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
9245    /// an unnoticed side effect of touching the function.
9246    #[test]
9247    fn denied_socket_domains_returns_exactly_the_documented_sets() {
9248        assert_eq!(
9249            denied_socket_domains(false),
9250            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
9251        );
9252        assert_eq!(
9253            denied_socket_domains(true),
9254            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
9255        );
9256    }
9257
9258    /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
9259    /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
9260    /// they really do agree with `libc`; on macOS this test compiles away, because
9261    /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
9262    /// constants are literals.
9263    #[cfg(target_os = "linux")]
9264    #[test]
9265    fn linux_socket_domain_constants_match_libc() {
9266        assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
9267        assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
9268        assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
9269        assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
9270        assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
9271    }
9272
9273    /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
9274    /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
9275    /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
9276    /// anything stated here.
9277    #[test]
9278    fn socket_domain_allow_and_deny_sets_never_overlap() {
9279        for unix_sockets_allowed in [false, true] {
9280            let denied = denied_socket_domains(unix_sockets_allowed);
9281            for domain in allowed_socket_domains(unix_sockets_allowed) {
9282                assert!(
9283                    !denied.contains(&domain),
9284                    "socket domain {domain} is both allowed and denied \
9285                     (unix_sockets_allowed = {unix_sockets_allowed})"
9286                );
9287            }
9288        }
9289    }
9290
9291    /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
9292    /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
9293    /// default-deny filter would silently keep unix sockets refused even after a capsule declared
9294    /// the capability.
9295    #[test]
9296    fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
9297        assert_eq!(
9298            allowed_socket_domains(false),
9299            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
9300        );
9301        assert_eq!(
9302            allowed_socket_domains(true),
9303            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
9304        );
9305    }
9306
9307    // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
9308    //
9309    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
9310    // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
9311    // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
9312    // the allowlist against a newer upstream profile.
9313
9314    #[test]
9315    fn allowlist_contains_no_syscall_that_must_stay_denied() {
9316        for name in SECCOMP_MUST_STAY_DENIED {
9317            assert!(
9318                !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
9319                "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
9320                 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
9321                 in the same edit and say why in the commit"
9322            );
9323        }
9324    }
9325
9326    /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
9327    /// so omitting them would refuse the child's own first `execve` and no capsule could run a
9328    /// shell tool at all. Exec is decided by the Landlock domain instead; see
9329    /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
9330    #[test]
9331    fn allowlist_permits_exec_because_landlock_now_decides_it() {
9332        for name in ["execve", "execveat"] {
9333            assert!(
9334                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
9335                "{name} must reach the kernel — the default action is a deny, so omitting it \
9336                 refuses the child's own exec and nothing runs"
9337            );
9338        }
9339    }
9340
9341    /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
9342    /// for a syscall by discarding every conditional chain already recorded for it. A bare
9343    /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
9344    /// denials at filter-build time, with nothing at runtime to show for it.
9345    #[test]
9346    fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
9347        assert!(
9348            !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
9349            "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
9350        );
9351    }
9352
9353    /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
9354    /// which is exactly the state where one of them later gets removed and the other is missed.
9355    #[test]
9356    fn allowlist_has_no_duplicates() {
9357        let mut seen = std::collections::HashSet::new();
9358        for name in SECCOMP_SYSCALL_ALLOWLIST {
9359            assert!(seen.insert(*name), "{name} appears twice in the allowlist");
9360        }
9361    }
9362
9363    /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
9364    /// still "pass" every other test here, while denying every syscall a shell needs.
9365    #[test]
9366    fn allowlist_covers_the_syscalls_no_process_can_run_without() {
9367        for name in [
9368            "read",
9369            "write",
9370            "openat",
9371            "close",
9372            "mmap",
9373            "munmap",
9374            "brk",
9375            "exit_group",
9376            "futex",
9377            "rt_sigreturn",
9378            "clone",
9379            "wait4",
9380            "getpid",
9381            "getdents64",
9382        ] {
9383            assert!(
9384                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
9385                "{name} is missing from the allowlist — no process could run under this filter"
9386            );
9387        }
9388    }
9389
9390    #[test]
9391    fn network_ip_allowed_matches_only_the_resolved_allowlist() {
9392        let allow = vec![IpAddr::from([127, 0, 0, 1])];
9393        assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
9394        assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
9395        assert!(
9396            !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
9397            "an empty resolved allowlist must deny every destination"
9398        );
9399    }
9400
9401    // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
9402
9403    #[test]
9404    fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
9405        // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
9406        // namespace, and a silent tier would imply it has no documented limits. It warns when a
9407        // shell is declared, and stays silent only when there is no subprocess to enforce on.
9408        assert_eq!(
9409            tier_warning(EnforcementTier::KernelFull, false),
9410            Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
9411        );
9412        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
9413    }
9414
9415    /// The sealed notice states what `/proc` is: the session's private procfs, with no host
9416    /// process in it, and the host's only where no private one can be mounted, then refused by
9417    /// Landlock with `stat(2)` on `/proc/<pid>` the one thing that still reaches outside.
9418    #[test]
9419    fn tier_warning_kernel_sealed_states_what_proc_is() {
9420        let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
9421        assert_eq!(code, W_SEC_005);
9422        assert!(!notice.contains("no PID namespace"), "{notice}");
9423        assert!(notice.contains("own PID namespace"), "{notice}");
9424        assert!(notice.contains("private procfs"), "{notice}");
9425        assert!(notice.contains("no host process appears"), "{notice}");
9426        assert!(
9427            notice.contains("Landlock refuses every open beneath it"),
9428            "{notice}"
9429        );
9430        assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
9431        assert!(!notice.contains("any process of the same user"), "{notice}");
9432        assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
9433    }
9434
9435    #[test]
9436    fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
9437        assert_eq!(
9438            tier_warning(EnforcementTier::KernelSeccompOnly, false),
9439            Some((W_SEC_002, SECCOMP_ONLY_WARNING))
9440        );
9441        assert_eq!(
9442            tier_warning(EnforcementTier::EnvironmentOnly, false),
9443            Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
9444        );
9445        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
9446        assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
9447        assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
9448    }
9449
9450    /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
9451    /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
9452    /// same condition refuses the launch outright, so a warning would describe a session that
9453    /// does not exist — and never for a capsule with no process tree to bound.
9454    #[test]
9455    fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
9456        assert_eq!(
9457            aggregate_bounding_warning(false, true, false),
9458            Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
9459        );
9460        assert_eq!(
9461            aggregate_bounding_warning(false, false, false),
9462            None,
9463            "a capsule that cannot spawn a subprocess has nothing to bound"
9464        );
9465        assert_eq!(
9466            aggregate_bounding_warning(true, true, false),
9467            None,
9468            "on Linux this case is a refused launch, not a warning"
9469        );
9470        assert_eq!(
9471            aggregate_bounding_warning(true, true, true),
9472            None,
9473            "a scope exists, so there is no gap"
9474        );
9475    }
9476
9477    /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
9478    /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
9479    /// would expect to stop a fork bomb does not.
9480    #[test]
9481    fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
9482        let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
9483        assert!(message.contains("per-uid"), "message was: {message}");
9484        assert!(message.contains("fork bomb"), "message was: {message}");
9485        assert!(message.contains("pids.max"), "message was: {message}");
9486    }
9487
9488    #[test]
9489    fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
9490        let temp = tempfile::tempdir().unwrap();
9491        warn_for_missing_aggregate_bounding(
9492            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9493            true,
9494            false,
9495        );
9496        let log = bootstrap_log_contents(temp.path());
9497
9498        if cfg!(target_os = "linux") {
9499            assert!(
9500                log.is_empty(),
9501                "Linux refuses the launch instead of warning: {log}"
9502            );
9503        } else {
9504            assert!(log.contains(W_SEC_010), "log was: {log}");
9505            assert!(
9506                log.contains(&security_warning_link(W_SEC_010)),
9507                "log was: {log}"
9508            );
9509        }
9510    }
9511
9512    fn warning_test_policy() -> CapabilityPolicy {
9513        CapabilityPolicy {
9514            shell_allow: vec!["bash".to_string()],
9515            network_allow: vec!["https://api.example.com".to_string()],
9516            ..CapabilityPolicy::default()
9517        }
9518    }
9519
9520    #[test]
9521    fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
9522        let temp = tempfile::tempdir().unwrap();
9523        warn_for_enforcement_tier(
9524            EnforcementTier::KernelFull,
9525            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9526            &warning_test_policy(),
9527        );
9528        let log = bootstrap_log_contents(temp.path());
9529        assert!(
9530            log.contains(W_SEC_005),
9531            "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
9532        );
9533        assert!(
9534            log.contains("denied, not absent"),
9535            "must state the scope's documented limit: paths outside the workdir are denied, not \
9536             absent: {log}"
9537        );
9538        assert!(
9539            log.contains("read+execute") && log.contains("outside the workdir"),
9540            "must describe what is now actually granted (narrow, derived read+execute outside \
9541             the workdir), not an unfixed grant bug: {log}"
9542        );
9543        assert!(
9544            log.contains(&security_warning_link(W_SEC_005)),
9545            "must link to the diagnostics doc page: {log}"
9546        );
9547    }
9548
9549    #[test]
9550    fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
9551        let temp = tempfile::tempdir().unwrap();
9552        warn_for_enforcement_tier(
9553            EnforcementTier::KernelFull,
9554            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9555            &CapabilityPolicy::default(),
9556        );
9557        assert_eq!(
9558            bootstrap_log_contents(temp.path()),
9559            "",
9560            "with no shell declared there is no subprocess to enforce on — stay silent"
9561        );
9562    }
9563
9564    #[test]
9565    fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
9566        let temp = tempfile::tempdir().unwrap();
9567        warn_for_enforcement_tier(
9568            EnforcementTier::KernelSeccompOnly,
9569            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9570            &warning_test_policy(),
9571        );
9572        let log = bootstrap_log_contents(temp.path());
9573        assert!(
9574            log.contains("Landlock"),
9575            "must name the missing primitive: {log}"
9576        );
9577        assert!(
9578            log.contains("filesystem"),
9579            "must name what is not enforced: {log}"
9580        );
9581        assert!(
9582            log.contains(W_SEC_002),
9583            "must carry its warning code: {log}"
9584        );
9585        assert!(
9586            log.contains(&security_warning_link(W_SEC_002)),
9587            "must link to the diagnostics doc page: {log}"
9588        );
9589    }
9590
9591    #[test]
9592    fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
9593        let temp = tempfile::tempdir().unwrap();
9594        warn_for_enforcement_tier(
9595            EnforcementTier::EnvironmentOnly,
9596            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9597            &warning_test_policy(),
9598        );
9599        let log = bootstrap_log_contents(temp.path());
9600        assert!(
9601            log.contains("permanent"),
9602            "must state the tier is permanent, not pending: {log}"
9603        );
9604        assert!(
9605            log.contains("bash"),
9606            "the still-accurate bash/network bypass warning must also fire: {log}"
9607        );
9608        assert!(
9609            log.contains(W_SEC_001),
9610            "must carry the tier warning code: {log}"
9611        );
9612        assert!(
9613            log.contains(W_SEC_003),
9614            "must carry the bash/network bypass warning code: {log}"
9615        );
9616    }
9617
9618    // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
9619
9620    struct ForcePrepareFailureGuard;
9621
9622    impl ForcePrepareFailureGuard {
9623        fn new() -> Self {
9624            FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
9625            Self
9626        }
9627    }
9628
9629    impl Drop for ForcePrepareFailureGuard {
9630        fn drop(&mut self) {
9631            FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
9632        }
9633    }
9634
9635    #[test]
9636    fn execute_shell_fails_closed_when_enforcement_setup_fails() {
9637        let temp = tempfile::tempdir().unwrap();
9638        let policy = CapabilityPolicy {
9639            shell_allow: vec!["bash".to_string()],
9640            ..CapabilityPolicy::default()
9641        };
9642        let marker = temp.path().join("spawned-anyway.marker");
9643        let script = format!("echo ran > '{}'", marker.display());
9644
9645        {
9646            let _guard = ForcePrepareFailureGuard::new();
9647            let error = crate::shell::execute_shell(
9648                "bash",
9649                &["-c", &script],
9650                &[],
9651                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9652                &policy,
9653                &ShellEnforcement::environment_only().with_test_session_dirs(),
9654            )
9655            .unwrap_err();
9656            assert!(
9657                error.to_string().contains("sandbox"),
9658                "the error must name what failed to initialize: {error}"
9659            );
9660            assert!(
9661                !marker.exists(),
9662                "no subprocess may be spawned at all when enforcement setup fails"
9663            );
9664        }
9665
9666        // Control run without the forced failure: the identical call spawns and writes the
9667        // marker — proving the marker really would have caught a spawn above.
9668        let result = crate::shell::execute_shell(
9669            "bash",
9670            &["-c", &script],
9671            &[],
9672            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9673            &policy,
9674            &ShellEnforcement::environment_only().with_test_session_dirs(),
9675        )
9676        .unwrap();
9677        assert_eq!(result.exit_code, 0);
9678        assert!(marker.exists());
9679    }
9680}
9681
9682#[cfg(target_os = "linux")]
9683#[cfg(test)]
9684#[allow(clippy::print_stdout, clippy::print_stderr)]
9685mod landlock_probe_isolation {
9686    /// The Landlock probe must leave the calling process exactly as it found it.
9687    ///
9688    /// Stated as "the sealed probe gives the same answer before and after" rather than by
9689    /// inspecting the process's Landlock domain, because there is no API to ask whether a task
9690    /// is landlocked — and because that framing is what actually broke: the probe restricted
9691    /// itself, and every later attempt to build a composed root was refused, on every host, in
9692    /// a way that looked like the host's fault.
9693    ///
9694    /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
9695    /// halves report the same failure and this passes trivially; on a capable one, an
9696    /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
9697    #[test]
9698    fn the_landlock_probe_does_not_restrict_the_calling_process() {
9699        let before = crate::namespace_probe::probe_namespaces().sealed_root;
9700        let _ = super::linux_enforce::probe_landlock_full_access();
9701        let after = crate::namespace_probe::probe_namespaces().sealed_root;
9702
9703        assert_eq!(
9704            before, after,
9705            "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
9706             it has restricted the calling process instead of a forked child, which permanently \
9707             forbids the mount family to this process and everything it spawns",
9708        );
9709    }
9710}
9711
9712#[cfg(all(test, target_os = "linux"))]
9713#[allow(clippy::print_stdout, clippy::print_stderr)]
9714mod linux_integration_tests {
9715    use std::path::Path;
9716
9717    use super::*;
9718
9719    /// Spread-base supplying the three *host-bounding* fields (`resource_limits`,
9720    /// `cgroup_scope`, `workdir_guard`) to the enforcement literals below, which spell out the
9721    /// policy half by hand because they need to pin `tier` explicitly.
9722    ///
9723    /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
9724    /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
9725    /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
9726    /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
9727    /// of the child `pre_exec` runs on every tier), and no cgroup scope or workdir guard, so each
9728    /// spawn gets `RLIMIT_NPROC` headroom over the uid's count at that spawn. The `tier` it
9729    /// carries is always overridden by the literal that spreads it; only the host-bounding tail
9730    /// is ever consumed from here.
9731    fn host_bounding_base() -> ShellEnforcement {
9732        // Every session holds a runtime directory, and a sealed one cannot compose a root
9733        // without it. Kept past the test: a clone may outlive the value.
9734        let base = tempfile::tempdir().unwrap().keep();
9735        ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
9736    }
9737
9738    /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
9739    /// It pins *which* rights of [`LANDLOCK_TARGET_ABI`] `apply_landlock_scope` hands the workdir,
9740    /// so re-adding device-node creation, or losing the move and truncate rights, becomes a test
9741    /// failure instead of a silent regression.
9742    ///
9743    /// It proves **nothing** about whether the kernel enforces that set — that is the manual
9744    /// acceptance procedure in
9745    /// `docs/content/reference/workdir-device-node-manual-verification.md` and the
9746    /// `kernel_tier_*` tests below, not this test.
9747    /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
9748    /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
9749    #[test]
9750    fn workdir_landlock_grant_withholds_device_and_socket_creation() {
9751        use landlock::{Access, AccessFs, ABI};
9752
9753        assert_eq!(
9754            LANDLOCK_TARGET_ABI, 3,
9755            "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
9756        );
9757        let all_v3 = AccessFs::from_all(ABI::V3);
9758        // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
9759        // unconditionally-withheld rights below are checked against the permissive case rather
9760        // than passing trivially because `Execute` happened to be absent too.
9761        let granted = linux_enforce::workdir_access_rights(true);
9762
9763        for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
9764            assert!(
9765                all_v3.contains(withheld),
9766                "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
9767                 test's premise (and the workdir grant's comment) needs revisiting"
9768            );
9769            assert!(
9770                !granted.contains(withheld),
9771                "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
9772                 by handle_access, withholding it here is what makes it denied rather than merely \
9773                 un-granted"
9774            );
9775        }
9776
9777        for added in [AccessFs::Refer, AccessFs::Truncate] {
9778            assert!(
9779                granted.contains(added),
9780                "the workdir grant must carry {added:?}: without Refer a move between workdir \
9781                 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
9782                 capsule's own files"
9783            );
9784        }
9785
9786        assert!(
9787            granted.contains(AccessFs::MakeFifo),
9788            "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
9789        );
9790
9791        let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
9792        assert_eq!(
9793            granted, expected,
9794            "the workdir_exec grant must be exactly Landlock ABI v3 minus \
9795             MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
9796             this test and the constant's comment together"
9797        );
9798    }
9799
9800    /// The workdir right set as a ruleset built at ABI v1 sees it, after `apply_landlock_scope`
9801    /// masks it to the handled set: exactly ABI v1 minus the three `Make*` rights, with neither
9802    /// `Refer` nor `Truncate`, so a 5.13–5.18 kernel is handed no right it does not know.
9803    #[test]
9804    fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
9805        use landlock::{Access, AccessFs, ABI};
9806
9807        let all_v1 = AccessFs::from_all(ABI::V1);
9808        let masked = linux_enforce::workdir_access_rights(true) & all_v1;
9809
9810        assert_eq!(
9811            masked,
9812            all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
9813            "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
9814        );
9815        assert!(!masked.contains(AccessFs::Refer));
9816        assert!(!masked.contains(AccessFs::Truncate));
9817        assert_eq!(
9818            linux_enforce::workdir_access_rights(false) & all_v1,
9819            masked & !AccessFs::Execute,
9820            "masking must not move the workdir_exec axis"
9821        );
9822    }
9823
9824    /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
9825    /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
9826    /// procedure in
9827    /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
9828    /// regression that matters here: flipping the default back — which would silently reopen the
9829    /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
9830    #[test]
9831    fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
9832        use landlock::AccessFs;
9833
9834        assert!(
9835            !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
9836            "the default must withhold Execute from the workdir — that withholding is the whole \
9837             of what enforces capabilities.shell.allow now that the exec supervisor is gone"
9838        );
9839        assert!(
9840            linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
9841            "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
9842             compile-and-run workflows it exists for silently stop working"
9843        );
9844
9845        // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
9846        assert_eq!(
9847            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9848            linux_enforce::workdir_access_rights(false),
9849            "workdir_exec must be a one-bit axis — no other right may ride along with it"
9850        );
9851    }
9852
9853    /// A sealed enforcement over a fresh runtime directory beside `accessible`.
9854    fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
9855        ShellEnforcement {
9856            tier: EnforcementTier::KernelSealed,
9857            ..ShellEnforcement::environment_only().with_test_session_dirs()
9858        }
9859        .with_runtime_dir(test_runtime_dir(base), None)
9860    }
9861
9862    /// The session's temporary folder's descriptor, as `prepare_enforcement` takes it from the
9863    /// held root.
9864    fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9865        enforcement
9866            .runtime_dir
9867            .as_ref()
9868            .and_then(|roots| roots.temp.as_ref())
9869            .map(|temp| Arc::new(temp.path_fd().unwrap()))
9870    }
9871
9872    /// The session's home folder's descriptor, as `prepare_enforcement` takes it from the held
9873    /// root.
9874    fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9875        enforcement
9876            .home_root()
9877            .map(|home| Arc::new(home.path_fd().unwrap()))
9878    }
9879
9880    /// The sealed sources take the session's temporary folder from the runtime directory roots,
9881    /// bind it from its own path, and add nothing to either workdir; the Landlock fds carry the
9882    /// folder's rule whenever one is handed in. Opening an fd is an ordinary `open()` — no
9883    /// Landlock call, no namespace, no privilege — so this runs on any Linux host.
9884    ///
9885    /// It proves **nothing** about whether the kernel then honors the rule built from that fd;
9886    /// the live tests below and the hand-run procedure in
9887    /// `docs/content/reference/sealed-containment-manual-verification.md` do.
9888    #[test]
9889    fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
9890        let accessible = tempfile::tempdir().unwrap();
9891        let runtime = tempfile::tempdir().unwrap();
9892        let enforcement = sealed_with_runtime_dir(runtime.path());
9893        let temp_dir_fd = temp_dir_fd_of(&enforcement);
9894        let sources = SealedSourceFds::open(
9895            &enforcement,
9896            temp_dir_fd.as_ref(),
9897            home_dir_fd_of(&enforcement).as_ref(),
9898        )
9899        .expect("a real, writable workdir must yield the sealed sources");
9900        assert_eq!(
9901            sources.tmp_store_path,
9902            enforcement.temp_dir_path().unwrap(),
9903            "the composed root binds the session's temporary folder at /tmp"
9904        );
9905        assert_eq!(
9906            sources.home_dir.1,
9907            enforcement.home_dir_path().unwrap(),
9908            "the composed root binds the session's home folder at its own path"
9909        );
9910        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9911            sealed: Some(&sources),
9912            temp_dir_fd,
9913            home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
9914            runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
9915            records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
9916            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9917        })
9918        .expect("a real, writable workdir must yield sealed Landlock fds");
9919
9920        assert!(fds.temp_dir_fd().is_some());
9921        assert!(
9922            fds.home_dir_fd().is_some(),
9923            "the home folder takes the temporary folder's rule"
9924        );
9925        assert!(fds.runtime_dir_fd().is_some());
9926        assert!(
9927            fds.records_dir_fd().is_some(),
9928            "the records directory takes the runtime directory's read-only rule"
9929        );
9930        assert_eq!(
9931            std::fs::read_dir(accessible.path()).unwrap().count(),
9932            0,
9933            "enabling sealed must add nothing to the work folder"
9934        );
9935
9936        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9937            accessible.path(),
9938        ))
9939        .expect("a real, writable workdir must yield Landlock fds on any tier");
9940        assert!(
9941            fds.temp_dir_fd().is_none(),
9942            "an enforcement holding no temporary folder grants none"
9943        );
9944        assert!(fds.home_dir_fd().is_none());
9945    }
9946
9947    /// A sealed root cannot be composed without the session's temporary folder: the refusal is
9948    /// the parent's, names the folder, and comes before anything is spawned.
9949    #[test]
9950    fn a_sealed_root_needs_the_temp_folder() {
9951        let runtime = tempfile::tempdir().unwrap();
9952        let mut roots = test_runtime_dir(runtime.path());
9953        roots.temp = None;
9954        let enforcement = ShellEnforcement {
9955            tier: EnforcementTier::KernelSealed,
9956            ..ShellEnforcement::environment_only().with_test_session_dirs()
9957        }
9958        .with_runtime_dir(roots, None);
9959        let error =
9960            SealedSourceFds::open(&enforcement, None, home_dir_fd_of(&enforcement).as_ref())
9961                .err()
9962                .expect("refused");
9963        assert!(error.contains("temporary folder"), "{error}");
9964    }
9965
9966    /// Nor without its home folder: the refusal names the missing home folder, and comes before
9967    /// anything is spawned or written.
9968    #[test]
9969    fn no_home_folder_refuses_a_sealed_root() {
9970        let runtime = tempfile::tempdir().unwrap();
9971        let roots = crate::runtime_dir::RuntimeDirRoots {
9972            home: None,
9973            ..test_runtime_dir(runtime.path())
9974        };
9975        let enforcement = ShellEnforcement {
9976            tier: EnforcementTier::KernelSealed,
9977            ..ShellEnforcement::environment_only().with_test_session_dirs()
9978        }
9979        .with_runtime_dir(roots, None);
9980        let error =
9981            SealedSourceFds::open(&enforcement, temp_dir_fd_of(&enforcement).as_ref(), None)
9982                .err()
9983                .expect("refused");
9984        assert!(error.contains("no home folder"), "{error}");
9985        assert!(!runtime
9986            .path()
9987            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
9988            .join("passwd")
9989            .exists());
9990    }
9991
9992    /// The synthetic account databases, written into `sealed-etc/` beside the runtime directory
9993    /// and opened by the same parent-side call.
9994    ///
9995    /// Like the temporary-folder test above it proves nothing about kernel enforcement — that is
9996    /// the hand-run in `docs/content/reference/sealed-containment-manual-verification.md`. It
9997    /// catches that the files exist before `build_sealed_root` binds them, that there is an fd to
9998    /// build each rule from, and that nothing is written into either workdir for them.
9999    #[test]
10000    fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
10001        let accessible = tempfile::tempdir().unwrap();
10002        let runtime = tempfile::tempdir().unwrap();
10003        let enforcement = sealed_with_runtime_dir(runtime.path());
10004        let sources = SealedSourceFds::open(
10005            &enforcement,
10006            temp_dir_fd_of(&enforcement).as_ref(),
10007            home_dir_fd_of(&enforcement).as_ref(),
10008        )
10009        .unwrap();
10010        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
10011            sealed: Some(&sources),
10012            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
10013        })
10014        .expect("a real, writable workdir must yield sealed Landlock fds");
10015
10016        let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
10017            .iter()
10018            .filter(|entry| entry.synthetic.is_some())
10019            .collect();
10020        assert_eq!(
10021            fds.sealed_identity_fd_count(),
10022            synthetic.len(),
10023            "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
10024        );
10025        let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
10026        for entry in &synthetic {
10027            let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
10028            assert!(
10029                path.is_file(),
10030                "{} must be written into sealed-etc/",
10031                entry.path
10032            );
10033        }
10034        assert!(
10035            !accessible.path().join(".mur-etc").exists(),
10036            "the work folder gained .mur-etc"
10037        );
10038
10039        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
10040            accessible.path(),
10041        ))
10042        .unwrap();
10043        assert_eq!(fds.sealed_identity_fd_count(), 0);
10044    }
10045
10046    /// A sealed root cannot be composed without the session's runtime directory: the refusal is
10047    /// the parent's, before anything is spawned.
10048    #[test]
10049    fn a_sealed_root_needs_the_runtime_directory() {
10050        let enforcement = ShellEnforcement {
10051            tier: EnforcementTier::KernelSealed,
10052            ..ShellEnforcement::environment_only()
10053        };
10054        let error = SealedSourceFds::open(&enforcement, None, None)
10055            .err()
10056            .expect("refused");
10057        assert!(error.contains("runtime directory"), "{error}");
10058    }
10059
10060    /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
10061    ///
10062    /// Both are the session's home folder's absolute path, `<records>/home`, and this asserts the
10063    /// end result — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and `os.environ["HOME"]`,
10064    /// and a disagreement between them is silent until a tool picks the wrong one. The uid/gid
10065    /// are checked against the home folder's own owner, which is where
10066    /// `ensure_sealed_identity_files` reads the capsule's identity from.
10067    #[test]
10068    fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
10069        use std::os::unix::fs::MetadataExt;
10070
10071        let runtime = tempfile::tempdir().unwrap();
10072        let roots = test_runtime_dir(runtime.path());
10073        let home = roots.home.clone().unwrap();
10074        let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
10075        let env = crate::shell::build_shell_env(
10076            &crate::types::CapabilityPolicy::default(),
10077            &[],
10078            Some(home.path()),
10079            None,
10080            None,
10081            None,
10082        )
10083        .unwrap();
10084
10085        let passwd = std::fs::read_to_string(&written[0]).unwrap();
10086        let owner = std::fs::metadata(home.path()).unwrap();
10087        let entry: Vec<&str> = passwd
10088            .lines()
10089            .last()
10090            .expect("the capsule's own entry is the last line")
10091            .split(':')
10092            .collect();
10093
10094        assert_eq!(entry[2], owner.uid().to_string(), "uid field");
10095        assert_eq!(entry[3], owner.gid().to_string(), "gid field");
10096        assert_eq!(
10097            Some(&entry[5].to_string()),
10098            env.get("HOME"),
10099            "pw_dir and $HOME are the same string or the entry is worse than useless",
10100        );
10101        assert_eq!(
10102            entry[5],
10103            runtime
10104                .path()
10105                .join(crate::records_dir::RECORDS_DIR_NAME)
10106                .join(crate::records_dir::HOME_SUBDIR)
10107                .to_str()
10108                .unwrap()
10109        );
10110
10111        let group = std::fs::read_to_string(&written[1]).unwrap();
10112        assert_eq!(
10113            group.lines().last().unwrap().split(':').nth(2),
10114            Some(owner.gid().to_string().as_str()),
10115            "gid field of the capsule's own group",
10116        );
10117    }
10118
10119    /// A workdir root over `work/` beside an `outside/` directory holding one sentinel file.
10120    fn sealed_planted() -> (
10121        tempfile::TempDir,
10122        PathBuf,
10123        PathBuf,
10124        crate::workdir_fs::WorkdirRoot,
10125    ) {
10126        let dir = tempfile::tempdir().unwrap();
10127        let accessible = dir.path().join("work");
10128        let outside = dir.path().join("outside");
10129        std::fs::create_dir_all(&accessible).unwrap();
10130        std::fs::create_dir_all(&outside).unwrap();
10131        std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
10132        let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
10133        (dir, root.path().to_path_buf(), outside, root)
10134    }
10135
10136    /// Staging twice rewrites `sealed-etc/passwd` in place: the inode a Landlock rule was bound to
10137    /// on the first call is the file the second call leaves.
10138    #[test]
10139    fn sealed_identity_files_keep_their_inode() {
10140        use std::os::unix::fs::MetadataExt;
10141
10142        let (_dir, _session, _outside, root) = sealed_planted();
10143        let runtime = tempfile::tempdir().unwrap();
10144        let roots = test_runtime_dir(runtime.path());
10145        let passwd = roots.sealed_etc.path().join("passwd");
10146        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
10147        let first = std::fs::metadata(&passwd).unwrap().ino();
10148        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
10149        assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
10150        assert!(root.take_refusals().is_empty());
10151    }
10152
10153    /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
10154    /// no fork, no spawn.
10155    ///
10156    /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
10157    /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
10158    /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
10159    /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
10160    /// manager creating build directories — and the `Execute` axis, which must track
10161    /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
10162    /// the same storage and a binary staged in one is the same bytes as a binary staged in the
10163    /// other.
10164    #[test]
10165    fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
10166        use landlock::AccessFs;
10167
10168        for workdir_exec in [false, true] {
10169            let rights = linux_enforce::workdir_access_rights(workdir_exec);
10170
10171            for needed in [
10172                AccessFs::WriteFile,
10173                AccessFs::ReadFile,
10174                AccessFs::ReadDir,
10175                AccessFs::MakeReg,
10176                AccessFs::MakeDir,
10177                AccessFs::RemoveFile,
10178                AccessFs::RemoveDir,
10179            ] {
10180                assert!(
10181                    rights.contains(needed),
10182                    "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
10183                     `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
10184                );
10185            }
10186
10187            assert_eq!(
10188                rights.contains(AccessFs::Execute),
10189                workdir_exec,
10190                "/tmp is backed by a directory inside the workdir, so Execute there must follow \
10191                 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
10192                 make /tmp a hole through which capsule-written bytes run",
10193            );
10194        }
10195
10196        assert_eq!(
10197            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
10198            linux_enforce::workdir_access_rights(false),
10199            "the /tmp rule and the workdir rule share one right-set binding in \
10200             apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
10201             that binding must be revisited together",
10202        );
10203    }
10204
10205    /// An enforcement on the unconfined tier, holding test session directories, with `declared`
10206    /// as its `capabilities.resources` block.
10207    fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
10208        ShellEnforcement {
10209            tier: EnforcementTier::Unconfined,
10210            declared_resources: declared,
10211            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
10212            proc_view: crate::proc_view::tier_proc_view(EnforcementTier::Unconfined),
10213            ..ShellEnforcement::environment_only()
10214        }
10215        .with_test_session_dirs()
10216    }
10217
10218    /// The `/proc/self/status` line starting `field`, of this process or of what `command`
10219    /// printed.
10220    fn status_line(status: &str, field: &str) -> String {
10221        status
10222            .lines()
10223            .find(|line| line.starts_with(field))
10224            .unwrap_or_else(|| panic!("no {field} line in {status}"))
10225            .to_string()
10226    }
10227
10228    /// What `script` prints when spawned through `prepare_enforcement` on `enforcement`.
10229    fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
10230        let mut command = std::process::Command::new("bash");
10231        command.args(["-c", script]);
10232        let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
10233            .expect("the unconfined tier installs nothing that can fail");
10234        assert!(matches!(supervisor, SupervisorHandle::Noop));
10235        let output = command.output().expect("bash runs");
10236        assert!(output.status.success(), "{output:?}");
10237        String::from_utf8(output.stdout).unwrap()
10238    }
10239
10240    /// A child spawned through `prepare_enforcement` on the unconfined tier keeps this process's
10241    /// `NoNewPrivs`, effective capabilities and network namespace: nothing is installed, so `sudo`
10242    /// works wherever the operator's sudoers allows it.
10243    #[test]
10244    fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
10245        let enforcement = unconfined_enforcement(Default::default());
10246        let child = spawn_unconfined(
10247            &enforcement,
10248            "cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
10249        );
10250        let own = std::fs::read_to_string("/proc/self/status").unwrap();
10251        for field in ["NoNewPrivs:", "CapEff:"] {
10252            assert_eq!(
10253                status_line(&child, field),
10254                status_line(&own, field),
10255                "{child}"
10256            );
10257        }
10258        let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
10259        assert!(
10260            child.contains(&format!("netns={}", own_netns.display())),
10261            "{child}"
10262        );
10263        assert_eq!(
10264            enforcement.session_netns.creations(),
10265            0,
10266            "the unconfined tier creates no network namespace"
10267        );
10268    }
10269
10270    /// The unconfined tier sets no rlimit a manifest does not declare — the child keeps this
10271    /// process's `RLIMIT_NOFILE` and `RLIMIT_CORE` — and sets exactly what one declares.
10272    #[test]
10273    fn an_unconfined_child_gets_only_the_declared_rlimits() {
10274        let read = |resource| {
10275            let limit = rustix::process::getrlimit(resource);
10276            let show = |value: Option<u64>| {
10277                value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
10278            };
10279            format!("{} {}", show(limit.current), show(limit.maximum))
10280        };
10281        let undeclared = spawn_unconfined(
10282            &unconfined_enforcement(Default::default()),
10283            "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
10284        );
10285        assert_eq!(
10286            undeclared,
10287            format!(
10288                "{}\n{}\n",
10289                read(rustix::process::Resource::Nofile),
10290                read(rustix::process::Resource::Core)
10291            )
10292        );
10293        let declared = spawn_unconfined(
10294            &unconfined_enforcement(murmur_artifact::ResourceCapabilities {
10295                max_open_files: Some(256),
10296                ..Default::default()
10297            }),
10298            "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
10299        );
10300        assert_eq!(
10301            declared,
10302            format!("256 256\n{}\n", read(rustix::process::Resource::Core))
10303        );
10304    }
10305
10306    #[test]
10307    fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
10308        let mut command = std::process::Command::new("true");
10309        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
10310        let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
10311            .expect("environment_only must never fail");
10312        assert!(matches!(supervisor, SupervisorHandle::Noop));
10313        assert_eq!(
10314            enforcement.session_netns.creations(),
10315            0,
10316            "a tier with no kernel sandbox must not create a network namespace"
10317        );
10318    }
10319
10320    // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
10321    // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
10322    // test passes while exercising a path that enforces nothing. The two tests below assert the
10323    // opposite direction — that allowlisted binaries still run — which any tier does evidence.
10324
10325    #[test]
10326    fn kernel_tier_allows_exec_within_shell_allowlist() {
10327        if crate::network_namespace::skip_without_egress_namespace(
10328            "kernel_tier_allows_exec_within_shell_allowlist",
10329        ) {
10330            return;
10331        }
10332        let tier = detect_enforcement_tier();
10333        if tier == EnforcementTier::EnvironmentOnly {
10334            eprintln!(
10335                "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
10336                 EnvironmentOnly on a Linux host — degrading gracefully"
10337            );
10338            return;
10339        }
10340
10341        let temp = tempfile::tempdir().unwrap();
10342        let policy = CapabilityPolicy {
10343            shell_allow: vec!["bash".to_string()],
10344            ..CapabilityPolicy::default()
10345        };
10346        let enforcement = ShellEnforcement {
10347            tier,
10348            network_allow_ips: Vec::new(),
10349            unix_sockets_allowed: policy.unix_sockets_allowed,
10350            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10351                &resolve_exec_allowlist(&policy.shell_allow),
10352            )),
10353            ..host_bounding_base()
10354        };
10355
10356        let result = crate::shell::execute_shell(
10357            "bash",
10358            &["-c", "exit 7"],
10359            &[],
10360            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10361            &policy,
10362            &enforcement,
10363        )
10364        .unwrap();
10365
10366        assert_eq!(
10367            result.exit_code, 7,
10368            "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
10369             proving the allowlist isn't a blanket deny"
10370        );
10371    }
10372
10373    #[test]
10374    fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
10375        if crate::network_namespace::skip_without_egress_namespace(
10376            "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
10377        ) {
10378            return;
10379        }
10380        let tier = detect_enforcement_tier();
10381        if tier == EnforcementTier::EnvironmentOnly {
10382            eprintln!(
10383                "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
10384                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
10385            );
10386            return;
10387        }
10388
10389        let temp = tempfile::tempdir().unwrap();
10390        let policy = CapabilityPolicy {
10391            shell_allow: vec!["bash".to_string(), "ls".to_string()],
10392            ..CapabilityPolicy::default()
10393        };
10394        let enforcement = ShellEnforcement {
10395            tier,
10396            network_allow_ips: Vec::new(),
10397            unix_sockets_allowed: policy.unix_sockets_allowed,
10398            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10399                &resolve_exec_allowlist(&policy.shell_allow),
10400            )),
10401            ..host_bounding_base()
10402        };
10403
10404        // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
10405        // bash — the case the no-regression scenario ("allowlisted binary still functions")
10406        // actually cares about.
10407        let result = crate::shell::execute_shell(
10408            "bash",
10409            &["-c", "ls"],
10410            &[],
10411            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10412            &policy,
10413            &enforcement,
10414        )
10415        .unwrap();
10416
10417        assert_eq!(
10418            result.exit_code, 0,
10419            "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
10420             (stderr: {})",
10421            result.stderr
10422        );
10423    }
10424
10425    #[test]
10426    fn kernel_tier_denies_network_connect_outside_allowlist() {
10427        if crate::network_namespace::skip_without_egress_namespace(
10428            "kernel_tier_denies_network_connect_outside_allowlist",
10429        ) {
10430            return;
10431        }
10432        let tier = detect_enforcement_tier();
10433        if tier == EnforcementTier::EnvironmentOnly {
10434            eprintln!(
10435                "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
10436                 EnvironmentOnly on a Linux host — degrading gracefully"
10437            );
10438            return;
10439        }
10440
10441        // A real listener on the *host*, so the destination port is genuinely open. The failure
10442        // below is therefore about reachability, not about a closed port: the subprocess runs in
10443        // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
10444        // listener does not exist at all.
10445        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
10446        let port = listener.local_addr().unwrap().port();
10447
10448        let temp = tempfile::tempdir().unwrap();
10449        let policy = CapabilityPolicy {
10450            shell_allow: vec!["bash".to_string()],
10451            ..CapabilityPolicy::default()
10452        };
10453        let enforcement = ShellEnforcement {
10454            tier,
10455            // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
10456            // capsule found its way to the endpoint.
10457            network_allow_rules: Vec::new(),
10458            network_allow_ips: Vec::new(),
10459            unix_sockets_allowed: policy.unix_sockets_allowed,
10460            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10461                &resolve_exec_allowlist(&policy.shell_allow),
10462            )),
10463            ..host_bounding_base()
10464        };
10465
10466        // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
10467        let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
10468        let result = crate::shell::execute_shell(
10469            "bash",
10470            &["-c", &script],
10471            &[],
10472            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10473            &policy,
10474            &enforcement,
10475        )
10476        .unwrap();
10477
10478        assert_ne!(
10479            result.exit_code, 0,
10480            "a direct connect() to a destination outside capabilities.network.allow must fail \
10481             even though the port is really open on the host — the subprocess is in its own \
10482             network namespace with no route to it"
10483        );
10484    }
10485
10486    /// The no-regression counterpart of the test above: enforcement must add denials for
10487    /// out-of-policy destinations without breaking permitted ones.
10488    ///
10489    /// The allowlist has to be declared rather than only pre-resolved, because it decides two
10490    /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
10491    /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
10492    ///
10493    /// Deliberately *not* a test of the security property — a green suite proves nothing about
10494    /// the kernel-level claim, and the real check is the hand-run procedure in
10495    /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
10496    /// asserts only that the permitted path still functions.
10497    #[test]
10498    fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
10499        if crate::network_namespace::skip_without_egress_namespace(
10500            "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
10501        ) {
10502            return;
10503        }
10504        let tier = detect_enforcement_tier();
10505        if tier == EnforcementTier::EnvironmentOnly {
10506            eprintln!(
10507                "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
10508                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
10509            );
10510            return;
10511        }
10512
10513        // A real listener on the host, answering one connection, so a success below means bytes
10514        // really crossed the namespace boundary through the proxy rather than merely connecting.
10515        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
10516        let port = listener.local_addr().unwrap().port();
10517        let server = std::thread::spawn(move || {
10518            if let Ok((mut stream, _)) = listener.accept() {
10519                use std::io::Write;
10520                let _ = stream.write_all(b"pong\n");
10521            }
10522        });
10523
10524        let temp = tempfile::tempdir().unwrap();
10525        // Naming the port is required, not incidental: it is what puts a listener on that port
10526        // inside the namespace. See `egress_proxy::egress_listen_ports`.
10527        let allow = vec![format!("http://127.0.0.1:{port}")];
10528        let policy = CapabilityPolicy {
10529            shell_allow: vec!["bash".to_string()],
10530            network_allow: allow.clone(),
10531            ..CapabilityPolicy::default()
10532        };
10533        let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
10534        let enforcement = ShellEnforcement {
10535            tier,
10536            egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
10537            network_allow_rules: rules,
10538            network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
10539            unix_sockets_allowed: policy.unix_sockets_allowed,
10540            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10541                &resolve_exec_allowlist(&policy.shell_allow),
10542            )),
10543            ..host_bounding_base()
10544        };
10545
10546        // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
10547        // and this measures the network path alone.
10548        let script =
10549            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
10550        let result = crate::shell::execute_shell(
10551            "bash",
10552            &["-c", &script],
10553            &[],
10554            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10555            &policy,
10556            &enforcement,
10557        )
10558        .unwrap();
10559        let _ = server.join();
10560
10561        assert_eq!(
10562            result.exit_code, 0,
10563            "a destination inside capabilities.network.allow must stay reachable — enforcement \
10564             adds denials for out-of-policy destinations, it does not degrade permitted ones \
10565             (stderr: {})",
10566            result.stderr
10567        );
10568        assert!(
10569            result.stdout.contains("pong"),
10570            "the host listener's bytes must come back through the proxy unmodified \
10571             (stdout: {:?}, stderr: {})",
10572            result.stdout,
10573            result.stderr
10574        );
10575    }
10576
10577    #[test]
10578    fn kernel_full_denies_filesystem_access_outside_workdir() {
10579        if crate::network_namespace::skip_without_egress_namespace(
10580            "kernel_full_denies_filesystem_access_outside_workdir",
10581        ) {
10582            return;
10583        }
10584        let tier = detect_enforcement_tier();
10585        if tier != EnforcementTier::KernelFull {
10586            eprintln!(
10587                "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
10588                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
10589                 instead"
10590            );
10591            return;
10592        }
10593
10594        let temp = tempfile::tempdir().unwrap();
10595        let policy = CapabilityPolicy {
10596            shell_allow: vec!["bash".to_string()],
10597            ..CapabilityPolicy::default()
10598        };
10599        let enforcement = ShellEnforcement {
10600            tier,
10601            network_allow_ips: Vec::new(),
10602            unix_sockets_allowed: policy.unix_sockets_allowed,
10603            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10604                &resolve_exec_allowlist(&policy.shell_allow),
10605            )),
10606            ..host_bounding_base()
10607        };
10608
10609        let result = crate::shell::execute_shell(
10610            "bash",
10611            &["-c", "cat /etc/hostname"],
10612            &[],
10613            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10614            &policy,
10615            &enforcement,
10616        )
10617        .unwrap();
10618
10619        assert_ne!(
10620            result.exit_code, 0,
10621            "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
10622        );
10623    }
10624
10625    // ---- moves, links and truncation under the ruleset ABI ----
10626    //
10627    // The tests below resolve their enforcement the way a session does — `ShellEnforcement::resolve`
10628    // over a live `HostProbe` at `scoped` — so the ruleset ABI they run under is the one the probe
10629    // fills. Each one that needs a newer ABI than this host offers prints a skip line and returns.
10630
10631    /// A `scoped` session's enforcement for `policy`, from a live probe, or `None` (after a skip
10632    /// line naming `test`) when this host cannot install a Landlock domain at `min_abi` or above.
10633    fn scoped_landlock_enforcement(
10634        test: &str,
10635        policy: &CapabilityPolicy,
10636        min_abi: u8,
10637    ) -> Option<ShellEnforcement> {
10638        if crate::network_namespace::skip_without_egress_namespace(test) {
10639            return None;
10640        }
10641        let probe = HostProbe::probe();
10642        let enforcement = ShellEnforcement::resolve_declared(
10643            policy,
10644            murmur_artifact::ContainmentClass::Scoped,
10645            probe.clone(),
10646        )
10647        .unwrap()
10648        .with_test_session_dirs();
10649        if enforcement.tier != EnforcementTier::KernelFull
10650            || enforcement.landlock_abi < Some(min_abi)
10651        {
10652            eprintln!(
10653                "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
10654                 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
10655                enforcement.tier,
10656                enforcement.landlock_abi,
10657                probe.landlock_kernel_abi()
10658            );
10659            return None;
10660        }
10661        Some(enforcement)
10662    }
10663
10664    fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
10665        let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
10666            .iter()
10667            .map(|name| name.to_string())
10668            .collect();
10669        shell_allow.extend(extra.iter().map(|name| name.to_string()));
10670        CapabilityPolicy {
10671            shell_allow,
10672            ..CapabilityPolicy::default()
10673        }
10674    }
10675
10676    fn run_bash_in(
10677        workdir: &Path,
10678        script: &str,
10679        policy: &CapabilityPolicy,
10680        enforcement: &ShellEnforcement,
10681    ) -> crate::shell::ShellResult {
10682        let result = crate::shell::execute_shell(
10683            "bash",
10684            &["-c", script],
10685            &[],
10686            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
10687            policy,
10688            enforcement,
10689        )
10690        .unwrap();
10691        println!(
10692            "exit {}\n--- stdout\n{}--- stderr\n{}---",
10693            result.exit_code, result.stdout, result.stderr
10694        );
10695        result
10696    }
10697
10698    /// A `<base>/work` workdir with a sibling `<base>/outside` on the same filesystem, so a
10699    /// refusal between them is Landlock's and never the VFS's cross-mount `EXDEV`.
10700    fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
10701        let base = tempfile::tempdir().unwrap();
10702        let work = base.path().join("work");
10703        let outside = base.path().join("outside");
10704        std::fs::create_dir(&work).unwrap();
10705        std::fs::create_dir(&outside).unwrap();
10706        (base, work, outside)
10707    }
10708
10709    /// With `Refer` granted on the workdir, `mv` between two of its folders is a real `rename(2)`
10710    /// (the inode survives), and `ln` and a directory reparent across folders both succeed.
10711    #[test]
10712    fn kernel_tier_moves_files_between_workdir_folders() {
10713        let test = "kernel_tier_moves_files_between_workdir_folders";
10714        let policy = coreutils_policy(&[]);
10715        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10716            return;
10717        };
10718        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
10719        assert_eq!(enforcement.landlock_abi, Some(3));
10720
10721        let temp = tempfile::tempdir().unwrap();
10722        let result = run_bash_in(
10723            temp.path(),
10724            r#"set -e
10725mkdir a b
10726echo payload > a/x
10727before=$(stat -c %i a/x)
10728mv a/x b/x
10729after=$(stat -c %i b/x)
10730echo "mv a/x b/x: inode $before -> $after"
10731[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
10732ln b/x a/y
10733[ "$(cat a/y)" = payload ] || exit 11
10734echo "ln b/x a/y: ok"
10735mkdir a/d
10736mv a/d b/d
10737[ -d b/d ] && [ ! -e a/d ] || exit 12
10738echo "mv a/d b/d: ok"
10739"#,
10740            &policy,
10741            &enforcement,
10742        );
10743
10744        assert_eq!(
10745            result.exit_code, 0,
10746            "a move, a hard link and a directory reparent between workdir folders must all \
10747             succeed under Refer"
10748        );
10749    }
10750
10751    /// On a Landlock tier the session's home folder is `$HOME` and carries the work folder's
10752    /// rights: a command writes, renames within and removes files under it. The records directory
10753    /// around it stays read-only: a command cannot write `out/result.txt`, create `children/x`, or
10754    /// move the home folder itself aside. Both directories sit under the host's `/tmp` here, so
10755    /// where `/tmp` is mapped this also proves the home folder is re-exposed writable over the
10756    /// read-only records directory.
10757    #[test]
10758    fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
10759        let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
10760        let policy = coreutils_policy(&["rm", "rmdir"]);
10761        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10762            return;
10763        };
10764        eprintln!(
10765            "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
10766            enforcement.tier,
10767            enforcement.landlock_abi,
10768            HostProbe::probe().landlock_kernel_abi(),
10769            enforcement.installed_temp_mapping()
10770        );
10771        let records = enforcement.records_root().unwrap().clone();
10772        records
10773            .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
10774            .unwrap();
10775        let home = enforcement.home_dir_path().unwrap().to_path_buf();
10776
10777        let workdir = tempfile::tempdir().unwrap();
10778        let result = run_bash_in(
10779            workdir.path(),
10780            &format!(
10781                r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
10782echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
10783  && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
10784if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
10785if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
10786if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
10787"#,
10788                home = home.display()
10789            ),
10790            &policy,
10791            &enforcement,
10792        );
10793
10794        assert_eq!(
10795            result.stdout,
10796            "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
10797            "stderr: {}",
10798            result.stderr
10799        );
10800        assert!(home.is_dir());
10801        assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
10802        assert!(!records.host_path("out/result.txt").exists());
10803        assert!(!records.host_path("children").exists());
10804        assert!(!home.with_extension("moved").exists());
10805        assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
10806    }
10807
10808    /// A delegated child's records, laid out where staging puts them — its records group,
10809    /// `records_group(<records dir>/children)`, beside the session's own directories — are out of
10810    /// reach of a parent's command: it can neither list the child's records directory nor read its
10811    /// result, while the runtime directory reads in the same command. The parent's runtime reads
10812    /// the child's answer and hands it over instead.
10813    ///
10814    /// The refusal is `Permission denied` where Landlock refuses the path, and `No such file or
10815    /// directory` where the command's private `/tmp` does not re-expose it.
10816    #[test]
10817    fn children_records_are_not_reachable_by_a_parents_commands() {
10818        let test = "children_records_are_not_reachable_by_a_parents_commands";
10819        let policy = coreutils_policy(&["ls"]);
10820        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10821            return;
10822        };
10823        let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
10824        std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
10825        let base = tempfile::tempdir().unwrap();
10826        let child_records = base
10827            .path()
10828            .join("records/children/worker-0123456789abcdef/ses_child");
10829        let result_file = child_records.join("out/result.txt");
10830        std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
10831        std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
10832
10833        let workdir = tempfile::tempdir().unwrap();
10834        let result = run_bash_in(
10835            workdir.path(),
10836            &format!(
10837                r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
10838if ls '{dir}'; then echo listed; else echo list-refused; fi
10839if cat '{file}'; then echo read; else echo read-refused; fi
10840"#,
10841                dir = child_records.display(),
10842                file = result_file.display(),
10843            ),
10844            &policy,
10845            &enforcement,
10846        );
10847
10848        assert_eq!(
10849            result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
10850            "stderr: {}",
10851            result.stderr
10852        );
10853        assert!(
10854            !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
10855            "the child's answer reached the parent's command: {result:?}"
10856        );
10857        for line in result.stderr.lines() {
10858            assert!(
10859                line.contains("Permission denied") || line.contains("No such file or directory"),
10860                "an unexpected refusal: {}",
10861                result.stderr
10862            );
10863        }
10864        assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
10865    }
10866
10867    /// The ruleset a 5.13–5.18 kernel gets, forced through the same `landlock_abi` field the probe
10868    /// fills: the capsule still launches, `ln` across folders is `EXDEV`, and `mv` survives only
10869    /// by copying.
10870    #[test]
10871    fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
10872        let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
10873        let policy = coreutils_policy(&[]);
10874        let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10875            return;
10876        };
10877        enforcement.landlock_abi = Some(1);
10878
10879        let temp = tempfile::tempdir().unwrap();
10880        let result = run_bash_in(
10881            temp.path(),
10882            r#"mkdir a b || exit 20
10883echo payload > a/x || exit 21
10884before=$(stat -c %i a/x)
10885mv a/x b/x || exit 22
10886[ -f b/x ] && [ ! -e a/x ] || exit 23
10887after=$(stat -c %i b/x)
10888echo "mv a/x b/x: inode $before -> $after"
10889[ "$before" != "$after" ] || exit 24
10890if ln b/x a/y; then exit 25; fi
10891exit 42
10892"#,
10893            &policy,
10894            &enforcement,
10895        );
10896
10897        assert_eq!(
10898            result.exit_code, 42,
10899            "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
10900             back to copy-and-delete (a new inode) and ln fails"
10901        );
10902        assert!(
10903            result.stderr.contains("Invalid cross-device link"),
10904            "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
10905            result.stderr
10906        );
10907    }
10908
10909    /// Nothing is moved or linked out of the workdir into a sibling folder on the same filesystem.
10910    #[test]
10911    fn kernel_tier_refuses_rename_out_of_the_workdir() {
10912        let test = "kernel_tier_refuses_rename_out_of_the_workdir";
10913        let policy = coreutils_policy(&[]);
10914        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10915            return;
10916        };
10917
10918        let (_base, work, outside) = work_and_outside();
10919        let script = format!(
10920            r#"echo payload > x || exit 20
10921if mv x '{outside}/x'; then exit 21; fi
10922if ln x '{outside}/x2'; then exit 22; fi
10923exit 0
10924"#,
10925            outside = outside.display()
10926        );
10927        let result = run_bash_in(&work, &script, &policy, &enforcement);
10928
10929        assert_eq!(
10930            result.exit_code, 0,
10931            "mv and ln out of the workdir must both fail"
10932        );
10933        assert!(
10934            work.join("x").exists(),
10935            "the source must stay in the workdir"
10936        );
10937        assert!(!outside.join("x").exists(), "nothing may be moved out");
10938        assert!(!outside.join("x2").exists(), "nothing may be linked out");
10939    }
10940
10941    /// Nothing is moved or linked into the workdir from a sibling folder on the same filesystem.
10942    #[test]
10943    fn kernel_tier_refuses_rename_into_the_workdir() {
10944        let test = "kernel_tier_refuses_rename_into_the_workdir";
10945        let policy = coreutils_policy(&[]);
10946        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10947            return;
10948        };
10949
10950        let (_base, work, outside) = work_and_outside();
10951        std::fs::write(outside.join("y"), "original\n").unwrap();
10952        let script = format!(
10953            r#"if mv '{outside}/y' ./y; then exit 21; fi
10954if ln '{outside}/y' ./y2; then exit 22; fi
10955exit 0
10956"#,
10957            outside = outside.display()
10958        );
10959        let result = run_bash_in(&work, &script, &policy, &enforcement);
10960
10961        assert_eq!(
10962            result.exit_code, 0,
10963            "mv and ln into the workdir must both fail"
10964        );
10965        assert_eq!(
10966            std::fs::read_to_string(outside.join("y")).unwrap(),
10967            "original\n",
10968            "the outside file must be untouched"
10969        );
10970        assert!(!work.join("y").exists(), "nothing may be moved in");
10971        assert!(!work.join("y2").exists(), "nothing may be linked in");
10972    }
10973
10974    /// A by-path `truncate(2)` on a host file outside the workdir: refused once the ruleset
10975    /// handles `Truncate` (ABI v3), and not mediated at all at ABI v1 — the gap v3 closes.
10976    #[test]
10977    fn kernel_tier_truncate_outside_the_workdir_is_refused() {
10978        use std::os::unix::fs::PermissionsExt;
10979
10980        let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
10981        let policy = coreutils_policy(&["perl"]);
10982        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10983            return;
10984        };
10985
10986        let (_base, work, outside) = work_and_outside();
10987        let target = outside.join("t");
10988        std::fs::write(&target, "keep\n").unwrap();
10989        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
10990        let script = format!(
10991            r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
10992            target.display()
10993        );
10994
10995        let refused = run_bash_in(&work, &script, &policy, &enforcement);
10996        assert_ne!(
10997            refused.exit_code, 0,
10998            "truncate(2) outside the workdir must be refused at ABI 3"
10999        );
11000        assert!(
11001            refused.stderr.contains("Permission denied"),
11002            "the refusal must be EACCES; stderr: {}",
11003            refused.stderr
11004        );
11005        assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
11006
11007        let mut at_v1 = enforcement.clone();
11008        at_v1.landlock_abi = Some(1);
11009        let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
11010        assert_eq!(
11011            unmediated.exit_code, 0,
11012            "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
11013        );
11014        assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
11015    }
11016
11017    /// Handling `Truncate` must not refuse what a capsule truncates legitimately: its own files,
11018    /// with `truncate` and with a `>` redirect over an existing file, and `/dev/null`.
11019    #[test]
11020    fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
11021        let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
11022        let policy = coreutils_policy(&["truncate"]);
11023        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
11024            return;
11025        };
11026
11027        let temp = tempfile::tempdir().unwrap();
11028        let result = run_bash_in(
11029            temp.path(),
11030            r#"set -e
11031echo hello > f && truncate -s 0 f && [ ! -s f ]
11032echo again > f && echo over > f && [ "$(cat f)" = over ]
11033: > g
11034echo discard > /dev/null
11035echo ok
11036"#,
11037            &policy,
11038            &enforcement,
11039        );
11040
11041        assert_eq!(
11042            result.exit_code, 0,
11043            "truncating inside the workdir and writing to /dev/null must keep working"
11044        );
11045    }
11046
11047    /// `uv venv` then `uv pip install <wheel>` in the workdir, offline: uv unpacks the wheel into
11048    /// a temporary folder in its cache and renames it into place. Passes at the probed ABI, and
11049    /// fails with `EXDEV` at ABI v1, so the pass is `Refer`'s.
11050    ///
11051    /// Ignored because it needs two host-side inputs a stock host does not have:
11052    /// `MURMUR_TEST_UV`, an absolute path to a `uv` binary, and `MURMUR_TEST_WHEEL`, an absolute
11053    /// path to `six-1.16.0-py2.py3-none-any.whl`, whose module and version the script checks after
11054    /// installing it. Run without them it
11055    /// panics naming the missing variable. `uv`, `python3` and its stdlib are granted by this
11056    /// test's policy only.
11057    #[test]
11058    #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
11059    fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
11060        let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
11061        let input = |name: &str| {
11062            let value = std::env::var(name)
11063                .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
11064            let path = PathBuf::from(value);
11065            assert!(
11066                path.is_absolute() && path.is_file(),
11067                "{name}={} is not a file",
11068                path.display()
11069            );
11070            path
11071        };
11072        let uv = input("MURMUR_TEST_UV");
11073        let wheel = input("MURMUR_TEST_WHEEL");
11074        let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
11075        let stdlib = std::process::Command::new(&python)
11076            .args([
11077                "-I",
11078                "-c",
11079                "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
11080            ])
11081            .output()
11082            .expect("host python3 runs");
11083        let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
11084
11085        let policy = CapabilityPolicy {
11086            shell_allow: vec![
11087                "bash".to_string(),
11088                uv.display().to_string(),
11089                python.display().to_string(),
11090            ],
11091            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
11092                binary: "python3".to_string(),
11093                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
11094                    path: stdlib,
11095                    list_dir: true,
11096                }],
11097            }],
11098            ..CapabilityPolicy::default()
11099        };
11100        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
11101            return;
11102        };
11103
11104        let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
11105        let script = format!(
11106            r#"set -e
11107export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
11108'{uv}' venv --python '{python}' .venv
11109'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
11110.venv/bin/python -c 'import six; print(six.__version__)'
11111"#,
11112            uv = uv.display(),
11113            python = python.display(),
11114        );
11115        let install = |enforcement: &ShellEnforcement| {
11116            let temp = tempfile::tempdir().unwrap();
11117            std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
11118            run_bash_in(temp.path(), &script, &policy, enforcement)
11119        };
11120
11121        let at_probed = install(&enforcement);
11122        assert_eq!(
11123            at_probed.exit_code, 0,
11124            "uv must install the wheel at ABI {:?}",
11125            enforcement.landlock_abi
11126        );
11127        assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
11128
11129        let mut at_v1 = enforcement.clone();
11130        at_v1.landlock_abi = Some(1);
11131        let refused = install(&at_v1);
11132        let output = format!("{}{}", refused.stdout, refused.stderr);
11133        assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
11134        assert!(
11135            output.contains("os error 18") || output.contains("Invalid cross-device link"),
11136            "at ABI v1 the install must fail with EXDEV: {output}"
11137        );
11138    }
11139
11140    // The two tests below exercise the derived Landlock grants. A green run is not evidence they
11141    // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
11142    // no Landlock domain is installed at all. Both tests print a skip line and return when the
11143    // host is not `KernelFull`.
11144
11145    #[test]
11146    fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
11147    {
11148        if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
11149            return;
11150        }
11151        let tier = detect_enforcement_tier();
11152        if tier != EnforcementTier::KernelFull {
11153            eprintln!(
11154                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11155                 kernel_full_runs_nontrivial_shell_allowlist_... requires \
11156                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11157                 This run does NOT execute the derived-grant Landlock code path at all, so a \
11158                 green result here is not evidence the workdir-scope exec bug is fixed — only a \
11159                 real KernelFull Linux host can prove that."
11160            );
11161            return;
11162        }
11163
11164        let temp = tempfile::tempdir().unwrap();
11165        // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
11166        // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
11167        // Linux hosts and both live outside the workdir, so this only succeeds if the derived
11168        // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
11169        let policy = CapabilityPolicy {
11170            shell_allow: vec!["bash".to_string(), "cat".to_string()],
11171            ..CapabilityPolicy::default()
11172        };
11173        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
11174        let enforcement = ShellEnforcement {
11175            tier,
11176            network_allow_ips: Vec::new(),
11177            unix_sockets_allowed: policy.unix_sockets_allowed,
11178            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
11179                &exec_allow_paths,
11180            )),
11181            ..host_bounding_base()
11182        };
11183
11184        // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
11185        // nested, dynamically-linked exec — the case the whole fix exists for.
11186        let result = crate::shell::execute_shell(
11187            "bash",
11188            &["-c", "echo landlock-grant-ok | cat"],
11189            &[],
11190            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11191            &policy,
11192            &enforcement,
11193        )
11194        .expect("execute_shell should return Ok, not Err");
11195
11196        assert_eq!(
11197            result.exit_code, 0,
11198            "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
11199             must let each exec and dynamic-link outside the workdir (stderr: {})",
11200            result.stderr
11201        );
11202        assert!(
11203            result.stdout.contains("landlock-grant-ok"),
11204            "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
11205            result.stdout,
11206            result.stderr
11207        );
11208    }
11209
11210    #[test]
11211    fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
11212        if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
11213            return;
11214        }
11215        let tier = detect_enforcement_tier();
11216        if tier != EnforcementTier::KernelFull {
11217            eprintln!(
11218                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11219                 kernel_full_denies_write_outside_workdir_... requires \
11220                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11221                 This run does NOT execute the Landlock code path, so a green result here is not \
11222                 evidence writes outside the workdir are actually denied — only a real KernelFull \
11223                 Linux host can prove that."
11224            );
11225            return;
11226        }
11227
11228        let workdir = tempfile::tempdir().unwrap();
11229        // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
11230        // reason a write can fail is the Landlock scope — not a missing directory.
11231        let outside = tempfile::tempdir().unwrap();
11232        let target = outside.path().join("should-not-be-written.txt");
11233
11234        let policy = CapabilityPolicy {
11235            shell_allow: vec!["bash".to_string()],
11236            ..CapabilityPolicy::default()
11237        };
11238        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
11239        let enforcement = ShellEnforcement {
11240            tier,
11241            network_allow_ips: Vec::new(),
11242            unix_sockets_allowed: policy.unix_sockets_allowed,
11243            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
11244                &exec_allow_paths,
11245            )),
11246            ..host_bounding_base()
11247        };
11248
11249        // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
11250        // write itself is what Landlock must refuse. The derived read+execute grants never widen
11251        // to write, so nothing outside the workdir (grant paths included) is writable.
11252        let script = format!("echo pwned > '{}'", target.display());
11253        let result = crate::shell::execute_shell(
11254            "bash",
11255            &["-c", &script],
11256            &[],
11257            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11258            &policy,
11259            &enforcement,
11260        )
11261        .expect("execute_shell should return Ok with a nonzero exit code, not Err");
11262
11263        assert!(
11264            !target.exists(),
11265            "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
11266        );
11267        assert_ne!(
11268            result.exit_code, 0,
11269            "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
11270        );
11271    }
11272
11273    /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
11274    /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
11275    /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
11276    fn kernel_full_enforcement(
11277        tier: EnforcementTier,
11278        policy: &CapabilityPolicy,
11279    ) -> ShellEnforcement {
11280        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
11281        let mut landlock_grants =
11282            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
11283        landlock_grants.extend(resolve_interpreter_runtime_grants(
11284            &policy.shell_interpreter_runtime,
11285        ));
11286        ShellEnforcement {
11287            tier,
11288            network_allow_ips: Vec::new(),
11289            unix_sockets_allowed: policy.unix_sockets_allowed,
11290            landlock_grants,
11291            ..host_bounding_base()
11292        }
11293    }
11294
11295    /// The harness's enforcement is the session's plus exactly its grants: each granted path as a
11296    /// host path (never executable), the binary and its `#!` interpreter as executable Landlock
11297    /// grants with their folders in the sealed binds. The session's own enforcement, which every
11298    /// shell command keeps, is unchanged.
11299    #[test]
11300    fn with_harness_grants_adds_the_paths_and_the_exec_files_and_nothing_else() {
11301        let session = kernel_full_enforcement(EnforcementTier::KernelFull, &allow_only(&[]));
11302        let granted = crate::host_paths::HostPath {
11303            declared: "~/harness-state".to_string(),
11304            path: PathBuf::from("/srv/harness-state"),
11305            access: crate::host_paths::HostPathAccess::ReadWrite,
11306            credential_locations: Vec::new(),
11307        };
11308        let bash = std::fs::canonicalize("/bin/bash").expect("bash is installed");
11309        // A harness script outside the runtime paths every composed root already holds, so its
11310        // folder is one the sealed root must bind.
11311        let scratch =
11312            tempfile::tempdir_in(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"))
11313                .unwrap();
11314        let script = std::fs::canonicalize(scratch.path())
11315            .unwrap()
11316            .join("harness.sh");
11317        std::fs::write(&script, "#!/bin/bash\n").unwrap();
11318        let grants = HarnessEnforcementGrants {
11319            granted_paths: vec![granted.clone()],
11320            exec_files: vec![script.clone(), bash.clone()],
11321        };
11322        let harness = session.with_harness_grants(&grants).expect("no overlap");
11323
11324        assert_eq!(harness.host_paths.len(), session.host_paths.len() + 1);
11325        assert_eq!(harness.host_paths.last(), Some(&granted));
11326        let added: Vec<&LandlockGrant> = harness
11327            .landlock_grants
11328            .iter()
11329            .skip(session.landlock_grants.len())
11330            .collect();
11331        assert!(
11332            added
11333                .iter()
11334                .any(|grant| grant.path == bash && grant.executable),
11335            "the harness binary is an executable grant: {added:?}"
11336        );
11337        assert!(
11338            added.iter().all(|grant| !grant.list_dir),
11339            "every exec grant is a non-listable file: {added:?}"
11340        );
11341        assert!(
11342            added.iter().all(|grant| grant.path != granted.path),
11343            "a granted path is never an executable Landlock grant: {added:?}"
11344        );
11345        assert!(
11346            added
11347                .iter()
11348                .any(|grant| grant.path == script && grant.executable),
11349            "the harness script is an executable grant: {added:?}"
11350        );
11351        assert!(
11352            harness
11353                .sealed_bind_dirs
11354                .iter()
11355                .any(|dir| Some(dir.as_path()) == script.parent()),
11356            "{:?}",
11357            harness.sealed_bind_dirs
11358        );
11359
11360        // The session's enforcement — what every shell command gets — is untouched.
11361        assert!(session.host_paths.iter().all(|path| path != &granted));
11362        assert!(session
11363            .landlock_grants
11364            .iter()
11365            .all(|grant| grant.path != bash));
11366    }
11367
11368    /// No Landlock domain, nothing to add: an unconfined or environment-only harness gets the
11369    /// session's enforcement as it is.
11370    #[test]
11371    fn with_harness_grants_adds_nothing_without_a_landlock_domain() {
11372        let grants = HarnessEnforcementGrants {
11373            granted_paths: vec![crate::host_paths::HostPath {
11374                declared: "/srv/x".to_string(),
11375                path: PathBuf::from("/srv/x"),
11376                access: crate::host_paths::HostPathAccess::Read,
11377                credential_locations: Vec::new(),
11378            }],
11379            exec_files: vec![PathBuf::from("/bin/sh")],
11380        };
11381        for tier in [
11382            EnforcementTier::Unconfined,
11383            EnforcementTier::EnvironmentOnly,
11384        ] {
11385            let session = ShellEnforcement {
11386                tier,
11387                ..host_bounding_base()
11388            };
11389            let harness = session.with_harness_grants(&grants).unwrap();
11390            assert_eq!(
11391                harness.host_paths.len(),
11392                session.host_paths.len(),
11393                "{tier:?}"
11394            );
11395            assert_eq!(
11396                harness.landlock_grants.len(),
11397                session.landlock_grants.len(),
11398                "{tier:?}"
11399            );
11400        }
11401    }
11402
11403    /// A real sealed run: the composed root's `/tmp` is the session's temporary folder in its
11404    /// records directory, `$HOME` and the synthetic `/etc/passwd`'s home are both its home folder,
11405    /// writable inside the root (`cd ~ && touch f`), the work folder gains nothing, and a delegated
11406    /// child's result beside the session's directories is not in the root at all.
11407    ///
11408    /// This drives a composed root end to end, so it needs a host that can actually reach
11409    /// `KernelSealed` and skips loudly on one that cannot; the parent-side halves are covered
11410    /// unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports the same host
11411    /// answer this checks.
11412    #[test]
11413    fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
11414        if crate::network_namespace::skip_without_egress_namespace(
11415            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
11416        ) {
11417            return;
11418        }
11419        let probe = HostProbe::probe();
11420        if probe.tier() != EnforcementTier::KernelSealed {
11421            eprintln!(
11422                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
11423                 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
11424                 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
11425                 {:?}. No composed root is built in this run.",
11426                probe.tier()
11427            );
11428            return;
11429        }
11430
11431        let accessible = tempfile::tempdir().unwrap();
11432        let policy = CapabilityPolicy {
11433            shell_allow: vec![
11434                "bash".to_string(),
11435                "cat".to_string(),
11436                "id".to_string(),
11437                "touch".to_string(),
11438                "getent".to_string(),
11439                "cut".to_string(),
11440            ],
11441            ..CapabilityPolicy::default()
11442        };
11443        let runtime = tempfile::tempdir().unwrap();
11444        let enforcement = ShellEnforcement::resolve_declared(
11445            &policy,
11446            murmur_artifact::ContainmentClass::Sealed,
11447            probe.clone(),
11448        )
11449        .expect("a sealed-capable host resolves sealed enforcement")
11450        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
11451        // A delegated child's result, beside the session's own directories as a child that has
11452        // staged leaves it, and out of the composed root's reach.
11453        let child_result = runtime
11454            .path()
11455            .join("children/worker-0123456789abcdef/out/result.txt");
11456        std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
11457        std::fs::write(&child_result, "the child's answer").unwrap();
11458        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
11459        eprintln!(
11460            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
11461             ruleset ABI {:?} (kernel {:?})",
11462            enforcement.tier,
11463            enforcement.landlock_abi,
11464            probe.landlock_kernel_abi()
11465        );
11466        let probe_name = format!("sealed-probe-{}", std::process::id());
11467
11468        let result = crate::shell::execute_shell(
11469            "bash",
11470            &[
11471                "-c",
11472                &format!(
11473                    "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
11474                     printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
11475                     getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
11476                     {{ cat '{child}' 2>&1 || true; }}",
11477                    child = child_result.display()
11478                ),
11479            ],
11480            &[],
11481            &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
11482            &policy,
11483            &enforcement,
11484        )
11485        .expect("execute_shell returns Ok even when the command itself fails");
11486
11487        assert_eq!(
11488            result.exit_code, 0,
11489            "stdout: {} stderr: {}",
11490            result.stdout, result.stderr
11491        );
11492        let mut lines = result.stdout.lines();
11493        assert_eq!(
11494            lines.next(),
11495            Some("t"),
11496            "the capsule's /tmp must be writable"
11497        );
11498        assert!(
11499            lines.next().is_some_and(|name| !name.is_empty()),
11500            "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
11501             {}",
11502            result.stdout
11503        );
11504        assert_eq!(
11505            lines.next(),
11506            Some("/tmp"),
11507            "TMPDIR names /tmp inside the root"
11508        );
11509        let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
11510        assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
11511        assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
11512        assert_eq!(
11513            lines.next(),
11514            Some("touched"),
11515            "~ is writable inside the root"
11516        );
11517        let child_read = lines.next().unwrap_or_default();
11518        assert!(
11519            child_read.contains("No such file or directory"),
11520            "a child's result is not in the composed root: {}",
11521            result.stdout
11522        );
11523        assert!(
11524            !result.stdout.contains("the child's answer"),
11525            "{}",
11526            result.stdout
11527        );
11528        assert!(Path::new(home).join("f").is_file());
11529
11530        let folder = enforcement.temp_dir_path().unwrap();
11531        assert_eq!(
11532            std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
11533            "t\n",
11534            "the bytes written to /tmp land in the session's temporary folder"
11535        );
11536        assert!(
11537            !Path::new("/tmp").join(&probe_name).exists(),
11538            "nothing reaches the host's /tmp"
11539        );
11540        assert!(runtime
11541            .path()
11542            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
11543            .join("passwd")
11544            .is_file());
11545        assert_eq!(
11546            std::fs::read_dir(accessible.path()).unwrap().count(),
11547            0,
11548            "enabling sealed must add nothing to the work folder"
11549        );
11550    }
11551
11552    // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
11553    // return when the host is not `KernelFull`.
11554
11555    #[test]
11556    fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
11557        if crate::network_namespace::skip_without_egress_namespace(
11558            "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
11559        ) {
11560            return;
11561        }
11562        let tier = detect_enforcement_tier();
11563        if tier != EnforcementTier::KernelFull {
11564            eprintln!(
11565                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11566                 kernel_full_interpreter_runtime_list_dir_false_... requires \
11567                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11568                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
11569                 result here is not evidence list_dir:false behaves correctly — only a real \
11570                 KernelFull Linux host can prove that."
11571            );
11572            return;
11573        }
11574
11575        let workdir = tempfile::tempdir().unwrap();
11576        // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
11577        let stdlib = tempfile::tempdir().unwrap();
11578        let module = stdlib.path().join("mod.py");
11579        std::fs::write(&module, "x = 1\n").unwrap();
11580
11581        let policy = CapabilityPolicy {
11582            shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
11583            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
11584                binary: "bash".to_string(),
11585                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
11586                    path: stdlib.path().to_string_lossy().into_owned(),
11587                    list_dir: false,
11588                }],
11589            }],
11590            ..CapabilityPolicy::default()
11591        };
11592        let enforcement = kernel_full_enforcement(tier, &policy);
11593
11594        // Opening the file by its exact known name must succeed — Landlock's read rights apply to
11595        // the subtree beneath the granted directory.
11596        let read = crate::shell::execute_shell(
11597            "bash",
11598            &["-c", &format!("cat '{}'", module.display())],
11599            &[],
11600            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11601            &policy,
11602            &enforcement,
11603        )
11604        .expect("execute_shell should return Ok");
11605        assert_eq!(
11606            read.exit_code, 0,
11607            "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
11608            read.stderr
11609        );
11610        assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
11611
11612        // Listing the directory's own entries must fail — ReadDir was not granted.
11613        let list = crate::shell::execute_shell(
11614            "bash",
11615            &["-c", &format!("ls '{}'", stdlib.path().display())],
11616            &[],
11617            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11618            &policy,
11619            &enforcement,
11620        )
11621        .expect("execute_shell should return Ok with a nonzero exit code");
11622        assert_ne!(
11623            list.exit_code, 0,
11624            "list_dir:false must deny enumerating the granted directory's own entries"
11625        );
11626    }
11627
11628    #[test]
11629    fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
11630        if crate::network_namespace::skip_without_egress_namespace(
11631            "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
11632        ) {
11633            return;
11634        }
11635        let tier = detect_enforcement_tier();
11636        if tier != EnforcementTier::KernelFull {
11637            eprintln!(
11638                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11639                 kernel_full_interpreter_runtime_list_dir_true_... requires \
11640                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11641                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
11642                 result here is not evidence list_dir:true (and its non-widening to the parent) \
11643                 behaves correctly — only a real KernelFull Linux host can prove that."
11644            );
11645            return;
11646        }
11647
11648        let workdir = tempfile::tempdir().unwrap();
11649        // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
11650        let parent = tempfile::tempdir().unwrap();
11651        let stdlib = parent.path().join("stdlib");
11652        std::fs::create_dir(&stdlib).unwrap();
11653        std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
11654
11655        let policy = CapabilityPolicy {
11656            shell_allow: vec!["bash".to_string(), "ls".to_string()],
11657            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
11658                binary: "bash".to_string(),
11659                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
11660                    path: stdlib.to_string_lossy().into_owned(),
11661                    list_dir: true,
11662                }],
11663            }],
11664            ..CapabilityPolicy::default()
11665        };
11666        let enforcement = kernel_full_enforcement(tier, &policy);
11667
11668        // Listing the granted directory must succeed.
11669        let list = crate::shell::execute_shell(
11670            "bash",
11671            &["-c", &format!("ls '{}'", stdlib.display())],
11672            &[],
11673            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11674            &policy,
11675            &enforcement,
11676        )
11677        .expect("execute_shell should return Ok");
11678        assert_eq!(
11679            list.exit_code, 0,
11680            "list_dir:true must permit enumerating the granted directory (stderr: {})",
11681            list.stderr
11682        );
11683        assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
11684
11685        // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
11686        // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
11687        let list_parent = crate::shell::execute_shell(
11688            "bash",
11689            &["-c", &format!("ls '{}'", parent.path().display())],
11690            &[],
11691            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11692            &policy,
11693            &enforcement,
11694        )
11695        .expect("execute_shell should return Ok with a nonzero exit code");
11696        assert_ne!(
11697            list_parent.exit_code, 0,
11698            "a grant on stdlib/ must not make its (ungranted) parent enumerable"
11699        );
11700    }
11701
11702    /// The `executable` axis, asserted against a real Landlock kernel.
11703    ///
11704    /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
11705    /// readable and enumerable without becoming a place to run programs from. It is asserted here
11706    /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
11707    /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
11708    /// would have to be driven through a composed root, which no unit test can reach.
11709    ///
11710    /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
11711    /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
11712    #[test]
11713    fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
11714        if crate::network_namespace::skip_without_egress_namespace(
11715            "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
11716        ) {
11717            return;
11718        }
11719        let host_tier = detect_enforcement_tier();
11720        if !matches!(
11721            host_tier,
11722            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11723        ) {
11724            eprintln!(
11725                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11726                 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
11727                 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
11728                 result is not evidence that withholding Execute denies execve."
11729            );
11730            return;
11731        }
11732
11733        let workdir = tempfile::tempdir().unwrap();
11734        // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
11735        let tree = tempfile::tempdir().unwrap();
11736        std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
11737        let runme = tree.path().join("runme");
11738        std::fs::copy("/bin/echo", &runme).unwrap();
11739
11740        let policy = CapabilityPolicy {
11741            shell_allow: vec!["bash".to_string(), "ls".to_string()],
11742            ..CapabilityPolicy::default()
11743        };
11744        let with_tree_grant = |executable: bool| {
11745            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11746            enforcement.landlock_grants.push(LandlockGrant {
11747                path: tree.path().to_path_buf(),
11748                list_dir: true,
11749                executable,
11750            });
11751            enforcement
11752        };
11753        let run = |enforcement: &ShellEnforcement, command: String| {
11754            crate::shell::execute_shell(
11755                "bash",
11756                &["-c", &command],
11757                &[],
11758                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11759                &policy,
11760                enforcement,
11761            )
11762            .expect("execute_shell should return Ok even when the command itself fails")
11763        };
11764
11765        let denied = with_tree_grant(false);
11766        let list = run(&denied, format!("ls '{}'", tree.path().display()));
11767        assert_eq!(
11768            list.exit_code, 0,
11769            "a non-executable grant must still be enumerable — that is its entire purpose \
11770             (stderr: {})",
11771            list.stderr
11772        );
11773        assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
11774
11775        let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
11776        assert_ne!(
11777            exec_denied.exit_code, 0,
11778            "withholding Execute must deny execve inside the granted tree; a program ran instead \
11779             (stdout: {})",
11780            exec_denied.stdout
11781        );
11782        assert!(
11783            !exec_denied.stdout.contains("ran"),
11784            "the binary produced output, so it executed: {}",
11785            exec_denied.stdout
11786        );
11787
11788        // The control: the identical grant with `executable: true` does run it. Without this the
11789        // assertion above could be passing for an unrelated reason (a bad path, a missing library).
11790        let allowed = with_tree_grant(true);
11791        let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
11792        assert_eq!(
11793            exec_allowed.exit_code, 0,
11794            "the same tree granted Execute must run (stderr: {})",
11795            exec_allowed.stderr
11796        );
11797        assert!(
11798            exec_allowed.stdout.contains("ran"),
11799            "stdout: {}",
11800            exec_allowed.stdout
11801        );
11802    }
11803
11804    /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
11805    /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
11806    ///
11807    /// Two kernel facts are pinned here. First, `ReadFile` alone —
11808    /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
11809    /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
11810    /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
11811    /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
11812    /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
11813    /// read, not die in `pre_exec`.
11814    ///
11815    /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
11816    #[test]
11817    fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
11818        if crate::network_namespace::skip_without_egress_namespace(
11819            "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
11820        ) {
11821            return;
11822        }
11823        let host_tier = detect_enforcement_tier();
11824        if !matches!(
11825            host_tier,
11826            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11827        ) {
11828            eprintln!(
11829                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11830                 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
11831                 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
11832                 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
11833                 a file is narrowed instead of refusing the launch."
11834            );
11835            return;
11836        }
11837
11838        let workdir = tempfile::tempdir().unwrap();
11839        // A trust-store stand-in: a regular file outside the workdir, granted on its own.
11840        let store = tempfile::tempdir().unwrap();
11841        let bundle = store.path().join("ca-certificates.crt");
11842        std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
11843
11844        let policy = CapabilityPolicy {
11845            shell_allow: vec!["bash".to_string(), "cat".to_string()],
11846            ..CapabilityPolicy::default()
11847        };
11848        let with_file_grant = |list_dir: bool| {
11849            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11850            enforcement.landlock_grants.push(LandlockGrant {
11851                path: bundle.clone(),
11852                list_dir,
11853                executable: false,
11854            });
11855            enforcement
11856        };
11857        let read = |enforcement: &ShellEnforcement| {
11858            crate::shell::execute_shell(
11859                "bash",
11860                &["-c", &format!("cat '{}'", bundle.display())],
11861                &[],
11862                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11863                &policy,
11864                enforcement,
11865            )
11866            .expect("execute_shell should return Ok even when the command itself fails")
11867        };
11868
11869        let honest = read(&with_file_grant(false));
11870        assert_eq!(
11871            honest.exit_code, 0,
11872            "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
11873             file entries rely on (stderr: {})",
11874            honest.stderr
11875        );
11876        assert!(
11877            honest.stdout.contains("BEGIN CERTIFICATE"),
11878            "stdout: {}",
11879            honest.stdout
11880        );
11881
11882        let overclaimed = read(&with_file_grant(true));
11883        assert_eq!(
11884            overclaimed.exit_code, 0,
11885            "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
11886             into an EINVAL that refuses the launch (stderr: {})",
11887            overclaimed.stderr
11888        );
11889        assert!(
11890            overclaimed.stdout.contains("BEGIN CERTIFICATE"),
11891            "stdout: {}",
11892            overclaimed.stdout
11893        );
11894    }
11895
11896    // ---- every distinct pre_exec setup failure is legible + fail-closed ----
11897    //
11898    // These force each of the three distinct sandbox setup failures and assert each produces its
11899    // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
11900    // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
11901    // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
11902    // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
11903    // short-circuits before any real Landlock syscall.
11904
11905    struct ForceNoNewPrivsFailureGuard;
11906
11907    impl ForceNoNewPrivsFailureGuard {
11908        fn new() -> Self {
11909            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
11910            Self
11911        }
11912    }
11913
11914    impl Drop for ForceNoNewPrivsFailureGuard {
11915        fn drop(&mut self) {
11916            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
11917        }
11918    }
11919
11920    struct ForceLandlockFailureGuard;
11921
11922    impl ForceLandlockFailureGuard {
11923        fn new() -> Self {
11924            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
11925            Self
11926        }
11927    }
11928
11929    impl Drop for ForceLandlockFailureGuard {
11930        fn drop(&mut self) {
11931            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
11932        }
11933    }
11934
11935    /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
11936    /// depend on any specific grant — they force the failure directly — so this exercises the
11937    /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
11938    fn kernel_full_empty_grants() -> ShellEnforcement {
11939        ShellEnforcement {
11940            tier: EnforcementTier::KernelFull,
11941            network_allow_ips: Vec::new(),
11942            unix_sockets_allowed: false,
11943            landlock_grants: Vec::new(),
11944            ..host_bounding_base()
11945        }
11946    }
11947
11948    /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
11949    /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
11950    /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
11951    fn workdir_resolution_error() -> String {
11952        let temp = tempfile::tempdir().unwrap();
11953        let missing = temp.path().join("does-not-exist-workdir");
11954        let marker = temp.path().join("never-spawned.marker");
11955        let mut command = std::process::Command::new("bash");
11956        command
11957            .arg("-c")
11958            .arg(format!("echo ran > '{}'", marker.display()));
11959
11960        let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
11961            .expect_err("an unresolvable workdir must fail before fork()");
11962        assert!(
11963            !marker.exists(),
11964            "prepare_enforcement must never spawn a subprocess: {error}"
11965        );
11966        error
11967    }
11968
11969    /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
11970    /// child-side failure active, asserts the target script never ran (marker absent), and returns
11971    /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
11972    fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
11973        child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
11974    }
11975
11976    /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
11977    /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
11978    /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
11979    /// identity out to the CLI instead of arriving as one more error string.
11980    fn child_setup_failure_typed<G>(
11981        enforcement: &ShellEnforcement,
11982        arm: impl FnOnce() -> G,
11983    ) -> crate::shell::ShellExecError {
11984        let temp = tempfile::tempdir().unwrap();
11985        let policy = CapabilityPolicy {
11986            shell_allow: vec!["bash".to_string()],
11987            ..CapabilityPolicy::default()
11988        };
11989        let marker = temp.path().join("spawned-anyway.marker");
11990        let script = format!("echo ran > '{}'", marker.display());
11991
11992        let _guard = arm();
11993        let error = crate::shell::execute_shell(
11994            "bash",
11995            &["-c", &script],
11996            &[],
11997            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11998            &policy,
11999            enforcement,
12000        )
12001        .expect_err("a forced child-side setup failure must make execute_shell return Err");
12002        assert!(
12003            !marker.exists(),
12004            "no subprocess may run when pre_exec setup fails: {error}"
12005        );
12006        error
12007    }
12008
12009    #[test]
12010    fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
12011        let error = workdir_resolution_error();
12012        assert!(
12013            error.contains("workdir") && error.contains("does-not-exist-workdir"),
12014            "the error must name the workdir path that failed to resolve: {error}"
12015        );
12016        assert!(
12017            !error.contains("os error 22"),
12018            "must not collapse to the bare EINVAL string: {error}"
12019        );
12020    }
12021
12022    #[test]
12023    fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
12024        if crate::network_namespace::skip_without_egress_namespace(
12025            "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
12026        ) {
12027            return;
12028        }
12029        let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
12030        assert!(
12031            error.contains("no_new_privs"),
12032            "the error must name the no_new_privs step specifically: {error}"
12033        );
12034        assert!(
12035            !error.contains("os error 22"),
12036            "must not collapse to the bare EINVAL string: {error}"
12037        );
12038    }
12039
12040    #[test]
12041    fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
12042        if crate::network_namespace::skip_without_egress_namespace(
12043            "pre_exec_landlock_failure_is_distinct_and_fails_closed",
12044        ) {
12045            return;
12046        }
12047        let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
12048        assert!(
12049            error.contains("landlock"),
12050            "the error must name the Landlock construction step specifically: {error}"
12051        );
12052        assert!(
12053            !error.contains("os error 22"),
12054            "must not collapse to the bare EINVAL string: {error}"
12055        );
12056    }
12057
12058    #[test]
12059    fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
12060        if crate::network_namespace::skip_without_egress_namespace(
12061            "pre_exec_setup_failures_produce_pairwise_distinct_messages",
12062        ) {
12063            return;
12064        }
12065        let workdir_msg = workdir_resolution_error();
12066        let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
12067        let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
12068
12069        let messages = [
12070            ("workdir", &workdir_msg),
12071            ("no_new_privs", &no_new_privs_msg),
12072            ("landlock", &landlock_msg),
12073        ];
12074        for (i, (left_name, left)) in messages.iter().enumerate() {
12075            for (right_name, right) in &messages[i + 1..] {
12076                assert_ne!(left, right, "{left_name} vs {right_name} must differ");
12077            }
12078        }
12079
12080        for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
12081            assert!(!message.is_empty(), "a distinct message must not be empty");
12082            assert!(
12083                !message.contains("os error 22"),
12084                "no failure may read as a bare EINVAL: {message}"
12085            );
12086        }
12087    }
12088
12089    /// The first half of the composed-root failure path: a `pre_exec`
12090    /// composed-root failure comes back out of `execute_shell` as the *typed*
12091    /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
12092    /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
12093    /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
12094    #[test]
12095    fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
12096        if crate::network_namespace::skip_without_egress_namespace(
12097            "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
12098        ) {
12099            return;
12100        }
12101        let error =
12102            child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
12103
12104        assert!(
12105            matches!(
12106                error,
12107                crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
12108            ),
12109            "a sealed-root diagnostic must survive as its own variant, not collapse into \
12110             ShellExecError::Failed: {error}"
12111        );
12112        let fatal = error
12113            .session_fatal()
12114            .expect("a composed-root failure must end the session, not just the tool call");
12115        assert!(
12116            matches!(
12117                fatal,
12118                crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
12119            ),
12120            "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
12121        );
12122        let rendered = error.to_string();
12123        assert!(
12124            rendered.contains("composed root") && rendered.contains("unshare"),
12125            "the message must name both the mechanism and the step that failed: {rendered}"
12126        );
12127        assert!(
12128            !rendered.contains("os error 22"),
12129            "must not collapse to the bare EINVAL string: {rendered}"
12130        );
12131    }
12132
12133    /// The contrast that makes the assertion above mean something: an equally fatal-looking
12134    /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
12135    /// in this path ends a session for a Landlock or `no_new_privs` problem.
12136    #[test]
12137    fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
12138        let landlock =
12139            child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
12140        assert!(
12141            landlock.session_fatal().is_none(),
12142            "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
12143        );
12144
12145        let no_new_privs = child_setup_failure_typed(
12146            &kernel_full_empty_grants(),
12147            ForceNoNewPrivsFailureGuard::new,
12148        );
12149        assert!(
12150            no_new_privs.session_fatal().is_none(),
12151            "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
12152        );
12153    }
12154
12155    // ---- one network namespace per session -------------------------------------------------
12156    //
12157    // Every test below drives real commands through `execute_shell`/`run_shell` against an
12158    // enforcement resolved the way `stage_session` resolves one, and reads what a later command of
12159    // the same session — or of another session — can reach.
12160
12161    /// `bash`, `nc` and `sleep`, plus `extra`: what a model starting and probing a server uses.
12162    fn server_policy(extra: &[&str]) -> CapabilityPolicy {
12163        CapabilityPolicy {
12164            shell_allow: ["bash", "nc", "sleep"]
12165                .iter()
12166                .chain(extra)
12167                .map(|binary| binary.to_string())
12168                .collect(),
12169            ..CapabilityPolicy::default()
12170        }
12171    }
12172
12173    /// A session's enforcement, resolved as `stage_session` resolves a `scoped` capsule's.
12174    fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
12175        let enforcement = ShellEnforcement::resolve_declared(
12176            policy,
12177            murmur_artifact::ContainmentClass::Scoped,
12178            HostProbe::probe(),
12179        )
12180        .expect("the policy resolves")
12181        .with_test_session_dirs();
12182        assert_eq!(
12183            enforcement.tier,
12184            EnforcementTier::KernelFull,
12185            "a scoped capsule on this host runs on KernelFull"
12186        );
12187        enforcement
12188    }
12189
12190    /// A one-shot server on `127.0.0.1:<port>` that answers its one connection with `reply`,
12191    /// backgrounded with its output redirected so the command that starts it returns at once.
12192    fn one_shot_server(reply: &str, port: u16) -> String {
12193        format!(
12194            "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
12195        )
12196    }
12197
12198    /// Connects to `127.0.0.1:<port>`, retrying for up to five seconds while the server comes up,
12199    /// and prints the first line it reads.
12200    fn retrying_client(port: u16) -> String {
12201        format!(
12202            "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
12203             sleep 0.1; done; read -r reply <&3; echo \"$reply\""
12204        )
12205    }
12206
12207    fn bash(
12208        script: &str,
12209        workdir: &Path,
12210        policy: &CapabilityPolicy,
12211        enforcement: &ShellEnforcement,
12212    ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
12213        crate::shell::execute_shell(
12214            "bash",
12215            &["-c", script],
12216            &[],
12217            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
12218            policy,
12219            enforcement,
12220        )
12221    }
12222
12223    fn pid_in(workdir: &Path, file: &str) -> i32 {
12224        std::fs::read_to_string(workdir.join(file))
12225            .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
12226            .trim()
12227            .parse()
12228            .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
12229    }
12230
12231    fn process_exists(pid: i32) -> bool {
12232        Path::new(&format!("/proc/{pid}")).exists()
12233    }
12234
12235    /// Whether `/proc/<pid>` is gone within `timeout`. A killed process stays visible until its
12236    /// parent — `init` for an orphan — reaps it, which takes a moment after the kill itself.
12237    fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
12238        let deadline = std::time::Instant::now() + timeout;
12239        while process_exists(pid) {
12240            if std::time::Instant::now() >= deadline {
12241                return false;
12242            }
12243            std::thread::sleep(std::time::Duration::from_millis(20));
12244        }
12245        true
12246    }
12247
12248    /// [`server_policy`] with `capabilities.network.allow: ["127.0.0.1:8091"]`, which makes the
12249    /// session's egress proxy hold port 8091.
12250    fn held_port_policy() -> CapabilityPolicy {
12251        CapabilityPolicy {
12252            network_allow: vec!["127.0.0.1:8091".to_string()],
12253            ..server_policy(&[])
12254        }
12255    }
12256
12257    /// The line a bind on 8091 under [`held_port_policy`] produces.
12258    fn held_8091_line() -> String {
12259        crate::held_ports::held_port_line(8091, &["127.0.0.1:8091"])
12260    }
12261
12262    /// A server on a held port still fails with the kernel's `EADDRINUSE`, and the session's
12263    /// observer records the bind, so the next drain names the port and the allow entry once.
12264    fn assert_a_held_bind_is_named(enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
12265        let temp = tempfile::tempdir().unwrap();
12266        assert!(
12267            enforcement.observed_held_ports().is_some(),
12268            "{:?} with a held port observes binds",
12269            enforcement.tier
12270        );
12271
12272        let server = bash("nc -l 127.0.0.1 8091", temp.path(), policy, enforcement)
12273            .expect("the server command runs");
12274        eprintln!(
12275            "tier {:?}; nc exit {}; stderr {:?}",
12276            enforcement.tier,
12277            server.exit_code,
12278            server.stderr.trim()
12279        );
12280        assert_ne!(server.exit_code, 0, "stdout: {}", server.stdout);
12281        assert!(
12282            server.stderr.contains("Address already in use"),
12283            "the kernel's EADDRINUSE is unchanged: {}",
12284            server.stderr
12285        );
12286        assert_eq!(enforcement.take_held_port_lines(), vec![held_8091_line()]);
12287        assert!(enforcement.take_held_port_lines().is_empty());
12288    }
12289
12290    /// `nc -l` on the port an allow entry holds fails with `EADDRINUSE`, and the session's drain
12291    /// names the port and the allow entry exactly once.
12292    #[test]
12293    fn a_bind_on_a_held_port_names_the_allow_entry() {
12294        if crate::network_namespace::skip_without_egress_namespace(
12295            "a_bind_on_a_held_port_names_the_allow_entry",
12296        ) {
12297            return;
12298        }
12299        let policy = held_port_policy();
12300        let enforcement = session_enforcement(&policy);
12301        assert_a_held_bind_is_named(&enforcement, &policy);
12302        enforcement.end_session();
12303    }
12304
12305    /// [`a_bind_on_a_held_port_names_the_allow_entry`] inside a composed root, where the bind is
12306    /// made from a user namespace nested below the session's.
12307    #[test]
12308    fn a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root() {
12309        if crate::network_namespace::skip_without_egress_namespace(
12310            "a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root",
12311        ) {
12312            return;
12313        }
12314        let probe = HostProbe::probe();
12315        if probe.tier() != EnforcementTier::KernelSealed {
12316            eprintln!(
12317                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
12318                 a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root needs a host that \
12319                 reaches KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); \
12320                 detected {:?}. No composed root is built in this run.",
12321                probe.tier()
12322            );
12323            return;
12324        }
12325        let policy = held_port_policy();
12326        let enforcement = ShellEnforcement::resolve_declared(
12327            &policy,
12328            murmur_artifact::ContainmentClass::Sealed,
12329            probe,
12330        )
12331        .expect("a sealed-capable host resolves sealed enforcement")
12332        .with_test_session_dirs();
12333        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
12334        assert_a_held_bind_is_named(&enforcement, &policy);
12335        enforcement.end_session();
12336    }
12337
12338    /// A server on a port no allow entry names binds, answers a later command of the session, and
12339    /// produces no line.
12340    #[test]
12341    fn a_server_on_a_port_no_entry_names_is_reachable_and_unreported() {
12342        if crate::network_namespace::skip_without_egress_namespace(
12343            "a_server_on_a_port_no_entry_names_is_reachable_and_unreported",
12344        ) {
12345            return;
12346        }
12347        let temp = tempfile::tempdir().unwrap();
12348        let policy = held_port_policy();
12349        let enforcement = session_enforcement(&policy);
12350        assert!(enforcement.observed_held_ports().is_some());
12351
12352        let server = bash(
12353            &one_shot_server("pong", 8092),
12354            temp.path(),
12355            &policy,
12356            &enforcement,
12357        )
12358        .expect("the server command runs");
12359        assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
12360        let client = bash(&retrying_client(8092), temp.path(), &policy, &enforcement)
12361            .expect("the client command runs");
12362        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
12363        assert_eq!(client.stdout.trim(), "pong");
12364        assert!(enforcement.take_held_port_lines().is_empty());
12365        assert_eq!(enforcement.session_netns.creations(), 1);
12366
12367        enforcement.end_session();
12368    }
12369
12370    /// A server backgrounded by a command that returns at once binds after the command finished;
12371    /// its listener stays observed, and the record waits for the session's next drain.
12372    #[test]
12373    fn a_held_bind_from_a_backgrounded_server_is_reported_after_its_command_returns() {
12374        if crate::network_namespace::skip_without_egress_namespace(
12375            "a_held_bind_from_a_backgrounded_server_is_reported_after_its_command_returns",
12376        ) {
12377            return;
12378        }
12379        let temp = tempfile::tempdir().unwrap();
12380        let policy = held_port_policy();
12381        let enforcement = session_enforcement(&policy);
12382
12383        let started = std::time::Instant::now();
12384        let command = bash(
12385            "nc -l 127.0.0.1 8091 >/dev/null 2>&1 & echo started",
12386            temp.path(),
12387            &policy,
12388            &enforcement,
12389        )
12390        .expect("the command runs");
12391        assert_eq!(command.exit_code, 0, "stderr: {}", command.stderr);
12392        assert_eq!(command.stdout.trim(), "started");
12393        assert!(started.elapsed() < std::time::Duration::from_secs(5));
12394
12395        let mut lines = Vec::new();
12396        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12397        while lines.is_empty() && std::time::Instant::now() < deadline {
12398            lines.extend(enforcement.take_held_port_lines());
12399            std::thread::sleep(std::time::Duration::from_millis(20));
12400        }
12401        std::thread::sleep(std::time::Duration::from_millis(300));
12402        lines.extend(enforcement.take_held_port_lines());
12403        assert_eq!(lines, vec![held_8091_line()]);
12404
12405        enforcement.end_session();
12406    }
12407
12408    /// The observer is decided per tier and allowlist: an empty allowlist and every tier without
12409    /// a Landlock domain get no held-port map, no listener socket, and today's filter.
12410    #[test]
12411    fn no_bind_observer_without_a_held_port_or_on_a_tier_without_landlock() {
12412        if crate::network_namespace::skip_without_egress_namespace(
12413            "no_bind_observer_without_a_held_port_or_on_a_tier_without_landlock",
12414        ) {
12415            return;
12416        }
12417        let temp = tempfile::tempdir().unwrap();
12418        let empty = session_enforcement(&server_policy(&[]));
12419        assert!(empty.bind_observation.is_none());
12420        assert!(empty.observed_held_ports().is_none());
12421        let mut command = std::process::Command::new("true");
12422        let handle = prepare_enforcement(&mut command, &empty, temp.path())
12423            .expect("the enforcement prepares");
12424        assert!(
12425            matches!(
12426                handle,
12427                SupervisorHandle::Linux {
12428                    bind_listener: None,
12429                    ..
12430                }
12431            ),
12432            "{handle:?}"
12433        );
12434        drop(command);
12435        empty.end_session();
12436
12437        let held = session_enforcement(&held_port_policy());
12438        assert_eq!(
12439            held.observed_held_ports()
12440                .map(crate::held_ports::HeldPorts::ports),
12441            Some(vec![8091])
12442        );
12443        for tier in [
12444            EnforcementTier::KernelSeccompOnly,
12445            EnforcementTier::EnvironmentOnly,
12446            EnforcementTier::Unconfined,
12447        ] {
12448            let other = ShellEnforcement {
12449                tier,
12450                ..held.clone()
12451            };
12452            assert!(other.observed_held_ports().is_none(), "{tier:?}");
12453            assert!(!observes_binds_on(tier), "{tier:?}");
12454        }
12455        assert!(ShellEnforcement::environment_only()
12456            .observed_held_ports()
12457            .is_none());
12458        held.end_session();
12459    }
12460
12461    /// Ending the session stops the observer thread and drops what was still pending.
12462    #[test]
12463    fn ending_the_session_stops_the_bind_observer_and_drops_pending_records() {
12464        if crate::network_namespace::skip_without_egress_namespace(
12465            "ending_the_session_stops_the_bind_observer_and_drops_pending_records",
12466        ) {
12467            return;
12468        }
12469        let temp = tempfile::tempdir().unwrap();
12470        let policy = held_port_policy();
12471        let enforcement = session_enforcement(&policy);
12472        let server = bash("nc -l 127.0.0.1 8091", temp.path(), &policy, &enforcement)
12473            .expect("the server command runs");
12474        assert_ne!(server.exit_code, 0);
12475        assert!(enforcement.session_netns.bind_observer_running());
12476
12477        enforcement.end_session();
12478        assert!(!enforcement.session_netns.bind_observer_running());
12479        assert!(enforcement.take_held_port_lines().is_empty());
12480    }
12481
12482    /// A server one command backgrounds answers a later, separate command of the same session.
12483    #[test]
12484    #[allow(clippy::print_stdout)]
12485    fn a_listener_started_by_one_command_answers_the_next() {
12486        if crate::network_namespace::skip_without_egress_namespace(
12487            "a_listener_started_by_one_command_answers_the_next",
12488        ) {
12489            return;
12490        }
12491        let temp = tempfile::tempdir().unwrap();
12492        let policy = server_policy(&[]);
12493        let enforcement = session_enforcement(&policy);
12494
12495        let started = std::time::Instant::now();
12496        let server = bash(
12497            &one_shot_server("pong", 8080),
12498            temp.path(),
12499            &policy,
12500            &enforcement,
12501        )
12502        .expect("the server command runs");
12503        let server_ms = started.elapsed().as_millis();
12504        assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
12505        assert!(
12506            started.elapsed() < std::time::Duration::from_secs(5),
12507            "a backgrounded server with its output redirected must not hold the command open"
12508        );
12509
12510        let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
12511            .expect("the client command runs");
12512        println!(
12513            "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
12514             namespaces created: {}",
12515            enforcement.tier,
12516            server.exit_code,
12517            client.exit_code,
12518            client.stdout.trim(),
12519            enforcement.session_netns.creations()
12520        );
12521        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
12522        assert_eq!(client.stdout.trim(), "pong");
12523        assert_eq!(enforcement.session_netns.creations(), 1);
12524
12525        enforcement.end_session();
12526    }
12527
12528    /// A foreground server demoted after its grace period keeps listening, and a later command
12529    /// reaches it; the demoted command then completes with what it received.
12530    #[test]
12531    #[allow(clippy::print_stdout)]
12532    fn a_demoted_server_stays_reachable_from_later_commands() {
12533        use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
12534
12535        if crate::network_namespace::skip_without_egress_namespace(
12536            "a_demoted_server_stays_reachable_from_later_commands",
12537        ) {
12538            return;
12539        }
12540        let temp = tempfile::tempdir().unwrap();
12541        let base = tempfile::tempdir().unwrap();
12542        let policy = server_policy(&[]);
12543        let enforcement =
12544            session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
12545        let (registry, mut reports) = DetachedRegistry::new();
12546
12547        let outcome = crate::shell::run_shell(
12548            "nc",
12549            &["-l", "127.0.0.1", "8080"],
12550            &[],
12551            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
12552            &policy,
12553            &enforcement,
12554            Some(DetachPolicy {
12555                grace: Some(std::time::Duration::ZERO),
12556                delivered: true,
12557                registry: Arc::clone(&registry),
12558                command: "-l 127.0.0.1 8080".to_string(),
12559                context_id: "ctx_demoted_server".to_string(),
12560                provenance: None,
12561                abandoned: None,
12562                canceled: None,
12563            }),
12564        )
12565        .expect("the server command runs");
12566        let crate::shell::ShellOutcome::Detached(info) = outcome else {
12567            panic!("a zero grace period demotes the server, got {outcome:?}");
12568        };
12569
12570        let client = bash(
12571            "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
12572             sleep 0.1; done; echo ping >&3",
12573            temp.path(),
12574            &policy,
12575            &enforcement,
12576        )
12577        .expect("the client command runs");
12578        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
12579
12580        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
12581        let completion = loop {
12582            match reports.try_recv() {
12583                Ok(DetachedReport::Completed(completion)) => break completion,
12584                Ok(DetachedReport::Lost(_)) => {}
12585                Err(_) if std::time::Instant::now() < deadline => {
12586                    std::thread::sleep(std::time::Duration::from_millis(20));
12587                }
12588                Err(error) => panic!("the demoted server never completed: {error}"),
12589            }
12590        };
12591        let log = std::fs::read_to_string(&completion.output_path)
12592            .expect("the demoted command's log is written");
12593        println!(
12594            "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
12595            info.work_id,
12596            client.exit_code,
12597            completion.work_id,
12598            completion.exit_code,
12599            log.contains("ping")
12600        );
12601        assert_eq!(completion.work_id, info.work_id);
12602        assert_eq!(completion.exit_code, 0, "log: {log}");
12603        assert!(log.contains("ping"), "log: {log}");
12604
12605        enforcement.end_session();
12606    }
12607
12608    /// A truncated command's log, named by its absolute path in the session's records directory,
12609    /// reads back under the session's own enforcement: through the shell interpreter and through
12610    /// a binary allowed on its own that gets the path as an argument. A write to it is refused,
12611    /// on any tier that installs Landlock.
12612    #[test]
12613    #[allow(clippy::print_stdout)]
12614    fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
12615        // A host that cannot give the session its network namespace refuses the session at
12616        // staging, so there is no session enforcement to run under.
12617        if crate::network_namespace::skip_without_egress_namespace(
12618            "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
12619        ) {
12620            return;
12621        }
12622        let base = home_tempdir();
12623        let workdir = home_tempdir();
12624        let policy = CapabilityPolicy {
12625            shell_allow: vec!["bash".to_string(), "grep".to_string()],
12626            ..CapabilityPolicy::default()
12627        };
12628        let enforcement = ShellEnforcement::resolve_declared(
12629            &policy,
12630            murmur_artifact::ContainmentClass::Scoped,
12631            HostProbe::probe(),
12632        )
12633        .expect("the policy resolves")
12634        .with_runtime_dir(test_runtime_dir(base.path()), None);
12635        let landlock = matches!(
12636            enforcement.tier,
12637            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
12638        );
12639        println!(
12640            "tier {:?}; landlock ABI {:?}",
12641            enforcement.tier,
12642            detect_landlock_abi()
12643        );
12644
12645        // A builtin loop: only `bash` and `grep` are executable under Landlock.
12646        let printed = run_bash_in(
12647            workdir.path(),
12648            "for ((i = 1; i <= 20000; i++)); do echo $i; done",
12649            &policy,
12650            &enforcement,
12651        );
12652        assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
12653        assert!(printed.truncated);
12654        let path = printed
12655            .full_output_path
12656            .clone()
12657            .expect("a truncated command names its log");
12658        assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
12659        let before = std::fs::read(&path).unwrap();
12660
12661        let through_bash = run_bash_in(
12662            workdir.path(),
12663            &format!("grep -x 20000 {path}"),
12664            &policy,
12665            &enforcement,
12666        );
12667        assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
12668        assert_eq!(through_bash.stdout, "20000\n");
12669
12670        let through_grep = crate::shell::execute_shell(
12671            "grep",
12672            &["-x", "20000", &path],
12673            &[],
12674            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12675            &policy,
12676            &enforcement,
12677        )
12678        .unwrap();
12679        assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
12680        assert_eq!(through_grep.stdout, "20000\n");
12681
12682        if landlock {
12683            let write = run_bash_in(
12684                workdir.path(),
12685                &format!("echo x >> {path}"),
12686                &policy,
12687                &enforcement,
12688            );
12689            assert_ne!(write.exit_code, 0, "a command appended to its own log");
12690            assert_eq!(std::fs::read(&path).unwrap(), before);
12691        } else {
12692            println!(
12693                "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
12694                 the write half needs Landlock; detected {:?}",
12695                enforcement.tier
12696            );
12697        }
12698        assert!(!workdir.path().join("logs").exists());
12699        enforcement.end_session();
12700    }
12701
12702    /// Two sessions each bind the same port on their own loopback, each reaches only its own
12703    /// server, and the host reaches neither.
12704    #[test]
12705    #[allow(clippy::print_stdout)]
12706    fn two_sessions_do_not_share_loopback() {
12707        if crate::network_namespace::skip_without_egress_namespace(
12708            "two_sessions_do_not_share_loopback",
12709        ) {
12710            return;
12711        }
12712        let port = {
12713            let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
12714            probe.local_addr().unwrap().port()
12715        };
12716        let policy = server_policy(&[]);
12717        let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
12718        let session_a = session_enforcement(&policy);
12719        let session_b = session_enforcement(&policy);
12720
12721        for (dir, session, reply) in [
12722            (&dir_a, &session_a, "pong-a"),
12723            (&dir_b, &session_b, "pong-b"),
12724        ] {
12725            let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
12726                .expect("the server command runs");
12727            assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
12728        }
12729        // Both servers bound the same port: each is still running, waiting for its one client.
12730        std::thread::sleep(std::time::Duration::from_millis(300));
12731        // Each pid file holds a pid in its session's PID namespace, where the two servers may
12732        // well share a number: both must be live host processes. Each is looked up in its own
12733        // session's namespace only, since tests running beside this one hold sessions whose
12734        // processes can carry the same number.
12735        let server_in = |dir: &tempfile::TempDir, session: &ShellEnforcement| -> Option<i32> {
12736            let join = session
12737                .session_netns
12738                .establish(
12739                    &session.egress_tcp_ports,
12740                    crate::egress_proxy::EgressPolicy::new(
12741                        session.network_allow_rules.clone(),
12742                        session.network_allow_ips.clone(),
12743                    ),
12744                    session.observed_held_ports(),
12745                )
12746                .expect("the session's namespace is established");
12747            let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
12748                .expect("the session's PID namespace handle is readable");
12749            let namespace_pid = pid_in(dir.path(), "server.pid").to_string();
12750            std::fs::read_dir("/proc")
12751                .expect("/proc is readable")
12752                .filter_map(Result::ok)
12753                .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
12754                .find(|pid| {
12755                    std::fs::read_link(format!("/proc/{pid}/ns/pid"))
12756                        .is_ok_and(|namespace| namespace == pid_ns)
12757                        && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
12758                            |status| {
12759                                status
12760                                    .lines()
12761                                    .find_map(|line| line.strip_prefix("NSpid:"))
12762                                    .and_then(|fields| fields.split_whitespace().last())
12763                                    == Some(namespace_pid.as_str())
12764                            },
12765                        )
12766                })
12767        };
12768        let servers: std::collections::HashSet<i32> = [(&dir_a, &session_a), (&dir_b, &session_b)]
12769            .into_iter()
12770            .filter_map(|(dir, session)| server_in(dir, session))
12771            .collect();
12772        assert_eq!(
12773            servers.len(),
12774            2,
12775            "a server's bind on {port} failed: {servers:?}"
12776        );
12777
12778        let host = std::net::TcpStream::connect(("127.0.0.1", port));
12779        let host_error = host.expect_err("the host must not reach a session's loopback server");
12780        assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
12781
12782        let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
12783            .expect("B's client runs");
12784        assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
12785
12786        // B's one-shot server is spent; A's still listens on the same port in A's namespace.
12787        let second_b = bash(
12788            &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
12789            dir_b.path(),
12790            &policy,
12791            &session_b,
12792        )
12793        .expect("B's second client runs");
12794
12795        let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
12796            .expect("A's client runs");
12797        println!(
12798            "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
12799             stderr {:?}; A read {:?}",
12800            client_b.stdout.trim(),
12801            second_b.exit_code,
12802            second_b.stderr.trim(),
12803            client_a.stdout.trim()
12804        );
12805        assert_eq!(client_b.stdout.trim(), "pong-b");
12806        assert_ne!(second_b.exit_code, 0);
12807        assert!(
12808            second_b.stderr.contains("Connection refused"),
12809            "stderr: {}",
12810            second_b.stderr
12811        );
12812        assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
12813        assert_eq!(client_a.stdout.trim(), "pong-a");
12814
12815        session_a.end_session();
12816        session_b.end_session();
12817    }
12818
12819    /// The session's one egress proxy serves every command, refuses what is not allowlisted,
12820    /// and is created once.
12821    #[test]
12822    #[allow(clippy::print_stdout)]
12823    fn the_session_proxy_serves_every_command() {
12824        if crate::network_namespace::skip_without_egress_namespace(
12825            "the_session_proxy_serves_every_command",
12826        ) {
12827            return;
12828        }
12829        // Answers every connection, so the first and third commands both get a reply.
12830        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
12831        let port = listener.local_addr().unwrap().port();
12832        std::thread::spawn(move || {
12833            for stream in listener.incoming() {
12834                use std::io::Write;
12835                if let Ok(mut stream) = stream {
12836                    let _ = stream.write_all(b"pong\n");
12837                }
12838            }
12839        });
12840
12841        let temp = tempfile::tempdir().unwrap();
12842        let policy = CapabilityPolicy {
12843            network_allow: vec![format!("http://127.0.0.1:{port}")],
12844            ..server_policy(&[])
12845        };
12846        let enforcement = session_enforcement(&policy);
12847        let through_proxy =
12848            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
12849
12850        let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
12851        let unlisted = bash(
12852            "exec 3<>/dev/tcp/10.255.255.1/9",
12853            temp.path(),
12854            &policy,
12855            &enforcement,
12856        )
12857        .unwrap();
12858        let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
12859        println!(
12860            "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
12861             created: {}",
12862            first.stdout.trim(),
12863            first.exit_code,
12864            unlisted.exit_code,
12865            unlisted.stderr.trim(),
12866            third.stdout.trim(),
12867            third.exit_code,
12868            enforcement.session_netns.creations()
12869        );
12870        assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
12871        assert_eq!(first.stdout.trim(), "pong");
12872        assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
12873        assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
12874        assert_eq!(third.stdout.trim(), "pong");
12875        assert_eq!(enforcement.session_netns.creations(), 1);
12876
12877        enforcement.end_session();
12878    }
12879
12880    /// Commands whose first spawns race each other still create exactly one namespace, and
12881    /// all of them land in it.
12882    #[test]
12883    #[allow(clippy::print_stdout)]
12884    fn concurrent_first_commands_share_one_namespace() {
12885        if crate::network_namespace::skip_without_egress_namespace(
12886            "concurrent_first_commands_share_one_namespace",
12887        ) {
12888            return;
12889        }
12890        let temp = tempfile::tempdir().unwrap();
12891        let policy = server_policy(&[]);
12892        let enforcement = session_enforcement(&policy);
12893        // One one-shot server per client, on its own port: a second client queued on a one-shot
12894        // `nc -l` would sit in its listen backlog and be closed unanswered when it exits.
12895        let servers = (8081..=8083)
12896            .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
12897            .collect::<Vec<_>>()
12898            .join("; ");
12899        let scripts = [
12900            servers,
12901            retrying_client(8081),
12902            retrying_client(8082),
12903            retrying_client(8083),
12904        ];
12905
12906        let barrier = std::sync::Barrier::new(scripts.len());
12907        let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
12908            let handles: Vec<_> = scripts
12909                .iter()
12910                .map(|script| {
12911                    let enforcement = enforcement.clone();
12912                    let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
12913                    scope.spawn(move || {
12914                        barrier.wait();
12915                        bash(script, workdir, policy, &enforcement).expect("the command runs")
12916                    })
12917                })
12918                .collect();
12919            handles
12920                .into_iter()
12921                .map(|handle| handle.join().unwrap())
12922                .collect()
12923        });
12924
12925        for (index, result) in results.iter().enumerate() {
12926            println!(
12927                "command {index}: exit {} stdout {:?}",
12928                result.exit_code,
12929                result.stdout.trim()
12930            );
12931            assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
12932        }
12933        for client in &results[1..] {
12934            assert_eq!(client.stdout.trim(), "pong");
12935        }
12936        println!(
12937            "namespaces created: {}",
12938            enforcement.session_netns.creations()
12939        );
12940        assert_eq!(enforcement.session_netns.creations(), 1);
12941
12942        enforcement.end_session();
12943    }
12944
12945    /// Ending the session kills everything it started — a `setsid` process and a background
12946    /// server included — releases the namespace, and refuses every command after.
12947    #[test]
12948    #[allow(clippy::print_stdout)]
12949    fn ending_the_session_kills_everything_it_started() {
12950        if crate::cgroup::skip_without_host_support(
12951            "ending_the_session_kills_everything_it_started",
12952        ) {
12953            return;
12954        }
12955        let temp = tempfile::tempdir().unwrap();
12956        let policy = server_policy(&["setsid"]);
12957        let scope =
12958            crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
12959                .expect("a delegated scope")
12960                .scope
12961                .expect("a required scope on Linux is never None");
12962        let enforcement =
12963            session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
12964
12965        let started = bash(
12966            "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
12967             nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
12968            temp.path(),
12969            &policy,
12970            &enforcement,
12971        )
12972        .expect("the command runs");
12973        assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
12974        // The pid files hold pids in the session's PID namespace.
12975        let sleeper = host_pid(&enforcement, pid_in(temp.path(), "sleeper.pid"));
12976        let server = host_pid(&enforcement, pid_in(temp.path(), "server.pid"));
12977        assert!(process_exists(sleeper), "the setsid sleeper is running");
12978        assert!(process_exists(server), "the background server is running");
12979
12980        let ending = std::time::Instant::now();
12981        enforcement.end_session();
12982        let took = ending.elapsed();
12983
12984        let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
12985        let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
12986        let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
12987        let after = bash("true", temp.path(), &policy, &enforcement);
12988        println!(
12989            "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
12990             gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
12991            took.as_millis(),
12992            procs.trim(),
12993            after.as_ref().map(|result| result.exit_code)
12994        );
12995        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
12996        assert!(sleeper_gone, "the setsid sleeper outlived its session");
12997        assert!(server_gone, "the background server outlived its session");
12998        assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
12999        let refusal = after
13000            .expect_err("a command after the session ended must be refused")
13001            .to_string();
13002        assert!(refusal.contains("session has ended"), "{refusal}");
13003    }
13004
13005    // ---- the session's temporary folder ----
13006    //
13007    // Each test below resolves its enforcement the way a session does, over a live `HostProbe`,
13008    // and maps `/tmp` with the answer staging would give. One that needs a private mount
13009    // namespace prints a `[SKIP-HOST]` line and returns on a host that cannot give one.
13010
13011    /// A directory under `HOME`, outside `/tmp`, removed when the value drops.
13012    fn home_tempdir() -> tempfile::TempDir {
13013        tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
13014    }
13015
13016    /// A session's enforcement at `class` from a live probe, holding a runtime directory, records
13017    /// directory and temporary folder under `base`, with the `/tmp` mapping staging decides for
13018    /// `workdir`; `None` after a skip line naming `test` when that mapping is not
13019    /// [`crate::temp_dir::TempMapping::MountNamespace`].
13020    fn mapped_enforcement(
13021        test: &str,
13022        policy: &CapabilityPolicy,
13023        class: murmur_artifact::ContainmentClass,
13024        base: &Path,
13025        workdir: &Path,
13026    ) -> Option<ShellEnforcement> {
13027        if crate::network_namespace::skip_without_egress_namespace(test) {
13028            return None;
13029        }
13030        let probe = HostProbe::probe();
13031        let mount_namespace_usable = probe.mount_namespace_usable();
13032        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
13033            .unwrap()
13034            .with_runtime_dir(test_runtime_dir(base), None);
13035        let mapping =
13036            crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
13037        if mapping != crate::temp_dir::TempMapping::MountNamespace {
13038            crate::runtime_err!(
13039                "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
13040                 {:?})",
13041                enforcement.tier
13042            );
13043            return None;
13044        }
13045        Some(enforcement.with_temp_mapping(mapping))
13046    }
13047
13048    /// The same session enforcement with the mount namespace probed as unusable: `KernelFull`,
13049    /// `/tmp` not mapped. `None` after a skip line on a host that has no `KernelFull` at all.
13050    fn unmapped_kernel_full_enforcement(
13051        test: &str,
13052        policy: &CapabilityPolicy,
13053        base: &Path,
13054        workdir: &Path,
13055    ) -> Option<ShellEnforcement> {
13056        if crate::network_namespace::skip_without_egress_namespace(test) {
13057            return None;
13058        }
13059        let live = HostProbe::probe();
13060        if !matches!(
13061            live.tier(),
13062            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
13063        ) {
13064            crate::runtime_err!(
13065                "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
13066                live.tier()
13067            );
13068            return None;
13069        }
13070        let refused = crate::namespace_probe::MountAttempt::Failed(
13071            crate::namespace_probe::MountAttemptError::Step {
13072                step: crate::namespace_probe::MountStep::UnshareMount,
13073                errno: libc::EPERM,
13074            },
13075        );
13076        let probe = HostProbe::from_parts(
13077            Some(true),
13078            crate::namespace_probe::NamespaceReading {
13079                mount: refused.clone(),
13080                sealed_root: refused,
13081                ..live.namespaces().clone()
13082            },
13083        )
13084        .with_landlock_kernel_abi(live.landlock_kernel_abi());
13085        assert!(!probe.mount_namespace_usable());
13086        let enforcement = ShellEnforcement::resolve_declared(
13087            policy,
13088            murmur_artifact::ContainmentClass::Scoped,
13089            probe,
13090        )
13091        .unwrap()
13092        .with_runtime_dir(test_runtime_dir(base), None);
13093        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
13094        let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
13095        assert_eq!(
13096            mapping,
13097            crate::temp_dir::TempMapping::NotMapped(
13098                crate::temp_dir::NotMappedReason::NoMountNamespace
13099            )
13100        );
13101        Some(enforcement.with_temp_mapping(mapping))
13102    }
13103
13104    /// A name no other test or run uses beneath `/tmp`.
13105    fn unique_tmp_name(label: &str) -> String {
13106        static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
13107        format!(
13108            "murmur-probe-{label}-{}-{}",
13109            std::process::id(),
13110            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
13111        )
13112    }
13113
13114    /// The parent-side spec re-exposes the work folder and the home folder read-write and the
13115    /// runtime and records directories and every grant path read-only, each at its exact path
13116    /// beneath `/tmp`, the home folder over the records directory it lies in.
13117    #[test]
13118    fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
13119        let base = tempfile::tempdir().unwrap();
13120        let workdir = tempfile::tempdir().unwrap();
13121        let grant_dir = tempfile::tempdir().unwrap();
13122        let grant = grant_dir.path().join("bin");
13123        std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
13124        let mut enforcement = ShellEnforcement {
13125            tier: EnforcementTier::KernelFull,
13126            ..ShellEnforcement::environment_only().with_test_session_dirs()
13127        }
13128        .with_runtime_dir(test_runtime_dir(base.path()), None)
13129        .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
13130        enforcement.landlock_grants =
13131            LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
13132        let folder = enforcement
13133            .runtime_dir
13134            .as_ref()
13135            .and_then(|roots| roots.temp.as_ref())
13136            .unwrap()
13137            .path_fd()
13138            .unwrap();
13139
13140        let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
13141
13142        let canonical = |path: &Path| {
13143            std::fs::canonicalize(path)
13144                .unwrap()
13145                .to_string_lossy()
13146                .into_owned()
13147        };
13148        let mut expected = vec![
13149            (canonical(workdir.path()), false),
13150            (
13151                canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
13152                true,
13153            ),
13154            (
13155                canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
13156                true,
13157            ),
13158            (
13159                canonical(
13160                    &base
13161                        .path()
13162                        .join(crate::records_dir::RECORDS_DIR_NAME)
13163                        .join(crate::records_dir::HOME_SUBDIR),
13164                ),
13165                false,
13166            ),
13167            (canonical(&grant), true),
13168        ];
13169        expected.sort();
13170        assert_eq!(spec.reexposed_sources(), expected);
13171    }
13172
13173    /// At `scoped` and at `advisory`, a literal `/tmp` path lands in the session's temporary
13174    /// folder, `TMPDIR` is `/tmp`, and the host's `/tmp` gains nothing.
13175    #[test]
13176    fn a_literal_tmp_path_reaches_the_session_temp_folder() {
13177        const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
13178        for class in [
13179            murmur_artifact::ContainmentClass::Scoped,
13180            murmur_artifact::ContainmentClass::Advisory,
13181        ] {
13182            let base = home_tempdir();
13183            let workdir = home_tempdir();
13184            let policy = coreutils_policy(&[]);
13185            let Some(enforcement) =
13186                mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
13187            else {
13188                return;
13189            };
13190            let name = unique_tmp_name("literal");
13191            let result = run_bash_in(
13192                workdir.path(),
13193                &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
13194                &policy,
13195                &enforcement,
13196            );
13197            assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
13198            assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
13199            let folder = enforcement.temp_dir_path().unwrap();
13200            assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
13201            assert!(
13202                !Path::new("/tmp").join(&name).exists(),
13203                "{class:?}: the host's /tmp gained {name}"
13204            );
13205        }
13206    }
13207
13208    /// `KernelSeccompOnly` maps `/tmp` through the same per-command mount namespace: the session
13209    /// enforcement with its tier set to seccomp-only, so no Landlock domain is installed.
13210    #[test]
13211    fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
13212        const TEST: &str =
13213            "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
13214        let base = home_tempdir();
13215        let workdir = home_tempdir();
13216        let policy = coreutils_policy(&[]);
13217        let Some(enforcement) = mapped_enforcement(
13218            TEST,
13219            &policy,
13220            murmur_artifact::ContainmentClass::Scoped,
13221            base.path(),
13222            workdir.path(),
13223        ) else {
13224            return;
13225        };
13226        let enforcement = ShellEnforcement {
13227            tier: EnforcementTier::KernelSeccompOnly,
13228            ..enforcement
13229        };
13230        assert_eq!(
13231            enforcement.installed_temp_mapping(),
13232            crate::temp_dir::TempMapping::MountNamespace
13233        );
13234        let name = unique_tmp_name("seccomp-only");
13235        let result = run_bash_in(
13236            workdir.path(),
13237            &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
13238            &policy,
13239            &enforcement,
13240        );
13241        assert_eq!(result.exit_code, 0, "{}", result.stderr);
13242        assert_eq!(result.stdout, "x\n/tmp\n");
13243        let folder = enforcement.temp_dir_path().unwrap();
13244        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
13245        assert!(
13246            !Path::new("/tmp").join(&name).exists(),
13247            "the host's /tmp gained {name}"
13248        );
13249    }
13250
13251    /// Two sessions' commands each see their own folder at `/tmp`.
13252    #[test]
13253    fn two_sessions_do_not_share_tmp() {
13254        const TEST: &str = "two_sessions_do_not_share_tmp";
13255        let policy = coreutils_policy(&[]);
13256        let (base_a, base_b) = (home_tempdir(), home_tempdir());
13257        let (work_a, work_b) = (home_tempdir(), home_tempdir());
13258        let class = murmur_artifact::ContainmentClass::Scoped;
13259        let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
13260            return;
13261        };
13262        let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
13263            return;
13264        };
13265        let name = unique_tmp_name("shared");
13266
13267        let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
13268        assert_eq!(first.exit_code, 0, "{}", first.stderr);
13269        let second = run_bash_in(
13270            work_b.path(),
13271            &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
13272            &policy,
13273            &b,
13274        );
13275        assert_eq!(second.exit_code, 0, "{}", second.stderr);
13276        assert_eq!(second.stdout, "b\n");
13277        let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
13278        assert_eq!(again.stdout, "a\n", "{}", again.stderr);
13279        assert_ne!(a.temp_dir_path(), b.temp_dir_path());
13280        assert!(!Path::new("/tmp").join(&name).exists());
13281    }
13282
13283    /// With the workdir, the runtime and records directories and `HOME` all beneath the host's
13284    /// `/tmp`, every one of them is still reached at its own path while `/tmp` is the folder.
13285    #[test]
13286    fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
13287        const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
13288        let base = tempfile::tempdir().unwrap();
13289        let workdir = tempfile::tempdir().unwrap();
13290        assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
13291        let policy = coreutils_policy(&[]);
13292        let Some(enforcement) = mapped_enforcement(
13293            TEST,
13294            &policy,
13295            murmur_artifact::ContainmentClass::Scoped,
13296            base.path(),
13297            workdir.path(),
13298        ) else {
13299            return;
13300        };
13301        std::fs::write(
13302            enforcement.runtime_dir_path().unwrap().join("probe"),
13303            b"runtime\n",
13304        )
13305        .unwrap();
13306        let name = unique_tmp_name("under");
13307        let result = run_bash_in(
13308            workdir.path(),
13309            &format!(
13310                "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
13311                 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
13312                 cat /tmp/{name}"
13313            ),
13314            &policy,
13315            &enforcement,
13316        );
13317        assert_eq!(result.exit_code, 0, "{}", result.stderr);
13318        assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
13319        assert_eq!(
13320            std::fs::read_to_string(workdir.path().join("f")).unwrap(),
13321            "a\n"
13322        );
13323
13324        // Only the paths the command needs are re-exposed, and only the workdir writably: a
13325        // sibling of the records directory is absent, and the records directory refuses a write
13326        // although it now lies beneath the folder's writable rule.
13327        let sibling = base.path().join("sibling");
13328        std::fs::write(&sibling, b"host\n").unwrap();
13329        let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
13330        std::fs::write(&record, b"{}\n").unwrap();
13331        let refused = run_bash_in(
13332            workdir.path(),
13333            &format!(
13334                "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
13335                sibling = sibling.display()
13336            ),
13337            &policy,
13338            &enforcement,
13339        );
13340        assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
13341        assert_ne!(refused.exit_code, 0);
13342        assert!(
13343            refused.stderr.contains("Read-only file system"),
13344            "{}",
13345            refused.stderr
13346        );
13347        assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
13348        let folder = enforcement.temp_dir_path().unwrap();
13349        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
13350        assert!(!Path::new("/tmp").join(&name).exists());
13351    }
13352
13353    /// Without a usable mount namespace, `KernelFull` maps nothing: a literal `/tmp` write is
13354    /// refused by Landlock, and `$TMPDIR` is the folder's absolute path and works.
13355    #[test]
13356    fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
13357        const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
13358        let base = home_tempdir();
13359        let workdir = home_tempdir();
13360        let policy = coreutils_policy(&[]);
13361        let Some(enforcement) =
13362            unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
13363        else {
13364            return;
13365        };
13366        let name = unique_tmp_name("refused");
13367        let refused = run_bash_in(
13368            workdir.path(),
13369            &format!("echo y > /tmp/{name}"),
13370            &policy,
13371            &enforcement,
13372        );
13373        assert_ne!(refused.exit_code, 0);
13374        assert!(
13375            refused.stderr.contains("Permission denied"),
13376            "{}",
13377            refused.stderr
13378        );
13379        assert!(!Path::new("/tmp").join(&name).exists());
13380
13381        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
13382        let works = run_bash_in(
13383            workdir.path(),
13384            "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
13385             \"$TEMP\"",
13386            &policy,
13387            &enforcement,
13388        );
13389        assert_eq!(works.exit_code, 0, "{}", works.stderr);
13390        let folder_text = folder.to_string_lossy();
13391        assert_eq!(
13392            works.stdout,
13393            format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
13394        );
13395        assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
13396    }
13397
13398    /// The records directory stays read-only apart from `tmp/`: no record can be linked, moved
13399    /// or appended into the folder, and the folder itself cannot be renamed — mapped and not.
13400    #[test]
13401    fn a_record_cannot_be_moved_or_linked_into_tmp() {
13402        const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
13403        let policy = coreutils_policy(&[]);
13404        for mapped in [true, false] {
13405            let base = home_tempdir();
13406            let workdir = home_tempdir();
13407            let enforcement = if mapped {
13408                mapped_enforcement(
13409                    TEST,
13410                    &policy,
13411                    murmur_artifact::ContainmentClass::Scoped,
13412                    base.path(),
13413                    workdir.path(),
13414                )
13415            } else {
13416                unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
13417            };
13418            let Some(enforcement) = enforcement else {
13419                return;
13420            };
13421            let records = enforcement.records_dir_path().unwrap().to_path_buf();
13422            let record = records.join("trace.jsonl");
13423            std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
13424            let before = std::fs::read(&record).unwrap();
13425            for script in [
13426                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
13427                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
13428                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
13429                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
13430                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
13431                "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
13432                "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
13433            ] {
13434                let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
13435                assert_ne!(
13436                    result.exit_code, 0,
13437                    "mapped: {mapped}: `{script}` succeeded"
13438                );
13439            }
13440            assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
13441            assert!(records
13442                .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
13443                .is_dir());
13444            assert!(!records.join("tmp-moved").exists());
13445        }
13446    }
13447
13448    /// The folder carries the workdir's right-set, `Execute` axis included: a binary copied into
13449    /// `/tmp` runs only with `workdir_exec: true`.
13450    #[test]
13451    fn tmp_follows_workdir_exec() {
13452        const TEST: &str = "tmp_follows_workdir_exec";
13453        for (workdir_exec, expected) in [(false, 126), (true, 0)] {
13454            let base = home_tempdir();
13455            let workdir = home_tempdir();
13456            let policy = CapabilityPolicy {
13457                workdir_exec_allowed: workdir_exec,
13458                ..coreutils_policy(&["cp", "true"])
13459            };
13460            let Some(enforcement) = mapped_enforcement(
13461                TEST,
13462                &policy,
13463                murmur_artifact::ContainmentClass::Advisory,
13464                base.path(),
13465                workdir.path(),
13466            ) else {
13467                return;
13468            };
13469            let result = run_bash_in(
13470                workdir.path(),
13471                "cp /bin/true /tmp/t && /tmp/t",
13472                &policy,
13473                &enforcement,
13474            );
13475            assert_eq!(
13476                result.exit_code, expected,
13477                "workdir_exec: {workdir_exec}: {}",
13478                result.stderr
13479            );
13480            if !workdir_exec {
13481                assert!(
13482                    result.stderr.contains("Permission denied"),
13483                    "{}",
13484                    result.stderr
13485                );
13486            }
13487        }
13488    }
13489
13490    /// A mapping that cannot be built fails the spawn with its own error and runs nothing.
13491    #[test]
13492    fn a_failed_tmp_mapping_spawns_nothing() {
13493        const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
13494        let base = home_tempdir();
13495        let workdir = home_tempdir();
13496        let policy = coreutils_policy(&[]);
13497        let Some(enforcement) = mapped_enforcement(
13498            TEST,
13499            &policy,
13500            murmur_artifact::ContainmentClass::Scoped,
13501            base.path(),
13502            workdir.path(),
13503        ) else {
13504            return;
13505        };
13506        let error = {
13507            let _forced = ForceTmpMapFailureGuard::new();
13508            crate::shell::execute_shell(
13509                "bash",
13510                &["-c", "echo ran > \"$(pwd)/marker\""],
13511                &[],
13512                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
13513                &policy,
13514                &enforcement,
13515            )
13516            .expect_err("a failed mapping fails the spawn")
13517        };
13518        let message = error.to_string();
13519        println!("{message}");
13520        assert!(
13521            message.contains("could not map /tmp onto this session's temporary folder"),
13522            "{message}"
13523        );
13524        assert!(
13525            matches!(error, crate::shell::ShellExecError::Failed(_)),
13526            "an ordinary spawn failure, not a session-fatal one: {error:?}"
13527        );
13528        assert!(!workdir.path().join("marker").exists(), "the command ran");
13529    }
13530
13531    /// A link swapped onto the folder's path after the session holds it redirects no bind: the
13532    /// child's open reaches another directory, and the spawn fails naming the step.
13533    #[test]
13534    fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
13535        const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
13536        let base = home_tempdir();
13537        let workdir = home_tempdir();
13538        let policy = coreutils_policy(&[]);
13539        let Some(enforcement) = mapped_enforcement(
13540            TEST,
13541            &policy,
13542            murmur_artifact::ContainmentClass::Scoped,
13543            base.path(),
13544            workdir.path(),
13545        ) else {
13546            return;
13547        };
13548        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
13549        let decoy = base.path().join("decoy");
13550        std::fs::create_dir(&decoy).unwrap();
13551        std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
13552        std::os::unix::fs::symlink(&decoy, &folder).unwrap();
13553
13554        let error = crate::shell::execute_shell(
13555            "bash",
13556            &["-c", "echo x > /tmp/swapped"],
13557            &[],
13558            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
13559            &policy,
13560            &enforcement,
13561        )
13562        .expect_err("a swapped folder fails the spawn")
13563        .to_string();
13564        println!("{error}");
13565        assert!(
13566            error.contains("could not map /tmp onto this session's temporary folder"),
13567            "{error}"
13568        );
13569        assert!(error.contains("opening the temporary folder"), "{error}");
13570        assert!(
13571            error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
13572            "{error}"
13573        );
13574        assert_eq!(
13575            std::fs::read_dir(&decoy).unwrap().count(),
13576            0,
13577            "the link's target was written"
13578        );
13579    }
13580
13581    /// A policy allowlisting `binary` with its interpreter runtime derived the way
13582    /// `stage_session` derives it, `work` excluded as the capsule-writable root, through a host
13583    /// facts cache of its own.
13584    fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
13585        let mut policy = CapabilityPolicy {
13586            shell_allow: vec![binary.to_string()],
13587            ..CapabilityPolicy::default()
13588        };
13589        let cache = tempfile::tempdir().unwrap();
13590        policy.shell_derived_interpreter_runtime =
13591            crate::interpreter_runtime::derive_interpreter_runtimes(
13592                &policy.shell_allow,
13593                &[],
13594                &[work],
13595                &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
13596            )
13597            .runtimes;
13598        policy
13599    }
13600
13601    /// What `program` prints when this host's `python3` runs it outside any sandbox.
13602    fn unsandboxed_python(program: &str) -> String {
13603        let output = std::process::Command::new("python3")
13604            .args(["-I", "-c", program])
13605            .output()
13606            .unwrap();
13607        assert!(output.status.success(), "{output:?}");
13608        String::from_utf8(output.stdout).unwrap().trim().to_string()
13609    }
13610
13611    /// Asserts the derived runtime holds the directories `encodings` and `_sqlite3` load from on
13612    /// this host, as the interpreter itself reports them, and returns the first: the stdlib.
13613    fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
13614        let stdlib = PathBuf::from(unsandboxed_python(
13615            "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
13616        ));
13617        let dynload = PathBuf::from(unsandboxed_python(
13618            "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
13619        ));
13620        let derived = &policy.shell_derived_interpreter_runtime;
13621        println!("derived: {derived:#?}");
13622        let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
13623        let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
13624        assert!(
13625            dirs.contains(&&canonical(&stdlib)),
13626            "{stdlib:?} not in {dirs:?}"
13627        );
13628        assert!(
13629            dirs.contains(&&canonical(&dynload)),
13630            "{dynload:?} not in {dirs:?}"
13631        );
13632        canonical(&stdlib)
13633    }
13634
13635    /// The program that opens `secret` and lists `parent`, each in its own `try`, printing the
13636    /// exception class name or `allowed`.
13637    fn python_probe_outside(secret: &Path, parent: &Path) -> String {
13638        format!(
13639            "import os\n\
13640             for label, act in (('open', lambda: open({secret:?}).read()), \
13641             ('listdir', lambda: os.listdir({parent:?}))):\n\
13642             \x20   try:\n\
13643             \x20       act(); print(label, 'allowed')\n\
13644             \x20   except Exception as error:\n\
13645             \x20       print(label, type(error).__name__)\n",
13646            secret = secret.to_string_lossy(),
13647            parent = parent.to_string_lossy(),
13648        )
13649    }
13650
13651    fn execute(
13652        binary: &str,
13653        args: &[&str],
13654        accessible: &Path,
13655        policy: &CapabilityPolicy,
13656        enforcement: &ShellEnforcement,
13657    ) -> crate::shell::ShellResult {
13658        let result = crate::shell::execute_shell(
13659            binary,
13660            args,
13661            &[],
13662            &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
13663            policy,
13664            enforcement,
13665        )
13666        .unwrap();
13667        println!(
13668            "exit {}\n--- stdout\n{}--- stderr\n{}---",
13669            result.exit_code, result.stdout, result.stderr
13670        );
13671        result
13672    }
13673
13674    /// A policy allowlisting `shell_allow` and reaching `network_allow`, with the trust store
13675    /// derived the way `stage_session` derives it, `work` excluded as the capsule-writable root.
13676    fn trust_store_policy(
13677        shell_allow: &[&str],
13678        network_allow: &[&str],
13679        work: &Path,
13680    ) -> CapabilityPolicy {
13681        let mut policy = CapabilityPolicy {
13682            shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
13683            network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
13684            ..CapabilityPolicy::default()
13685        };
13686        policy.shell_trust_store = crate::trust_store::derive_trust_store(
13687            &policy.shell_allow,
13688            &policy.network_allow,
13689            &[work],
13690        );
13691        println!("derived trust store: {:#?}", policy.shell_trust_store);
13692        policy
13693    }
13694
13695    /// The first CA bundle file of the candidate table this host has.
13696    fn host_ca_bundle() -> Option<PathBuf> {
13697        crate::trust_store::TRUST_STORE_CANDIDATES
13698            .iter()
13699            .filter(|candidate| !candidate.directory)
13700            .map(|candidate| PathBuf::from(candidate.path))
13701            .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
13702    }
13703
13704    /// An allowlisted `curl` in a capsule with a network allowlist and no `interpreter_runtime`
13705    /// block verifies a server certificate under `scoped`.
13706    #[test]
13707    fn a_scoped_curl_with_network_allow_completes_an_https_request() {
13708        let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
13709        if find_on_path("curl").is_none() {
13710            eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
13711            return;
13712        }
13713        let unsandboxed = std::process::Command::new("curl")
13714            .args([
13715                "-sS",
13716                "-o",
13717                "/dev/null",
13718                "-w",
13719                "%{http_code}",
13720                "--max-time",
13721                "15",
13722            ])
13723            .arg("https://example.com/")
13724            .output()
13725            .unwrap();
13726        if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
13727            eprintln!(
13728                "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
13729                 the sandbox; it gave {unsandboxed:?}"
13730            );
13731            return;
13732        }
13733        let (_base, work, _outside) = work_and_outside();
13734        let policy = trust_store_policy(&["curl"], &["example.com"], &work);
13735        assert!(policy.shell_interpreter_runtime.is_empty());
13736        assert!(
13737            !policy.shell_trust_store.is_empty(),
13738            "this host has a trust store the candidate table names"
13739        );
13740        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13741            return;
13742        };
13743
13744        let fetched = execute(
13745            "curl",
13746            &[
13747                "-sS",
13748                "-o",
13749                "/dev/null",
13750                "-w",
13751                "%{http_code}",
13752                "--max-time",
13753                "30",
13754                "https://example.com/",
13755            ],
13756            &work,
13757            &policy,
13758            &enforcement,
13759        );
13760        enforcement.end_session();
13761        assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
13762        assert_eq!(fetched.stdout, "200");
13763    }
13764
13765    /// The trust store reaches a command only when the capsule declares a network allowlist.
13766    #[test]
13767    fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
13768        let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
13769        assert!(
13770            crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
13771        );
13772        let Some(bundle) = host_ca_bundle() else {
13773            eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
13774            return;
13775        };
13776        let bundle_arg = bundle.to_str().unwrap();
13777        let (_base, work, _outside) = work_and_outside();
13778
13779        let offline = trust_store_policy(&["cat"], &[], &work);
13780        assert!(offline.shell_trust_store.is_empty());
13781        let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
13782        assert!(!online.shell_trust_store.is_empty());
13783        let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
13784            return;
13785        };
13786        for derived in &online.shell_trust_store {
13787            assert!(
13788                !enforcement
13789                    .landlock_grants
13790                    .iter()
13791                    .any(|grant| grant.path == derived.path),
13792                "{derived:?} granted without network.allow"
13793            );
13794        }
13795        let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
13796        enforcement.end_session();
13797        assert_ne!(denied.exit_code, 0);
13798        assert!(
13799            denied.stderr.contains("Permission denied"),
13800            "{}",
13801            denied.stderr
13802        );
13803
13804        let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
13805            return;
13806        };
13807        let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
13808        enforcement.end_session();
13809        assert_eq!(read.exit_code, 0, "{}", read.stderr);
13810        assert!(
13811            read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
13812            "{}",
13813            &read.stdout[..read.stdout.len().min(200)]
13814        );
13815    }
13816
13817    /// A `scoped` command reads a declared need and lists a granted need directory, while a file
13818    /// beside them stays denied and the granted file, though executable on disk, never runs.
13819    #[test]
13820    fn a_scoped_command_reads_a_declared_need_and_nothing_beside_it() {
13821        use std::os::unix::fs::PermissionsExt;
13822        let test = "a_scoped_command_reads_a_declared_need_and_nothing_beside_it";
13823        // Outside `/tmp`, which a scoped command sees as its own private folder.
13824        let base = home_tempdir();
13825        let need = base.path().join("need");
13826        std::fs::create_dir_all(need.join("share")).unwrap();
13827        let library = need.join("lib.so.1");
13828        std::fs::write(&library, "#!/bin/sh\necho ran\n").unwrap();
13829        std::fs::set_permissions(&library, std::fs::Permissions::from_mode(0o755)).unwrap();
13830        std::fs::write(need.join("share/font.txt"), "font").unwrap();
13831        let secret = need.join("secret.txt");
13832        std::fs::write(&secret, "secret").unwrap();
13833        let (_work_base, work, _outside) = work_and_outside();
13834        let mut policy = allow_only(&["cat", "sh", "ls"]);
13835        policy.shell_declared_needs =
13836            super::tests::granted_needs(&[(library.clone(), false), (need.join("share"), true)]);
13837        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13838            return;
13839        };
13840        assert!(enforcement
13841            .landlock_grants
13842            .iter()
13843            .filter(|grant| grant.path.starts_with(&need))
13844            .all(|grant| !grant.executable));
13845
13846        let read = execute(
13847            "cat",
13848            &[library.to_str().unwrap()],
13849            &work,
13850            &policy,
13851            &enforcement,
13852        );
13853        let beside = execute(
13854            "cat",
13855            &[secret.to_str().unwrap()],
13856            &work,
13857            &policy,
13858            &enforcement,
13859        );
13860        let listed = execute(
13861            "ls",
13862            &[need.join("share").to_str().unwrap()],
13863            &work,
13864            &policy,
13865            &enforcement,
13866        );
13867        let ran = execute(
13868            "sh",
13869            &["-c", library.to_str().unwrap()],
13870            &work,
13871            &policy,
13872            &enforcement,
13873        );
13874        enforcement.end_session();
13875        assert_eq!(read.exit_code, 0, "{}", read.stderr);
13876        assert!(read.stdout.contains("echo ran"), "{}", read.stdout);
13877        assert_ne!(beside.exit_code, 0);
13878        assert!(
13879            beside.stderr.contains("Permission denied"),
13880            "{}",
13881            beside.stderr
13882        );
13883        assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
13884        assert!(listed.stdout.contains("font.txt"), "{}", listed.stdout);
13885        assert_eq!(ran.exit_code, 126, "{}", ran.stderr);
13886        assert!(ran.stderr.contains("Permission denied"), "{}", ran.stderr);
13887        assert!(!ran.stdout.contains("ran"), "{}", ran.stdout);
13888    }
13889
13890    // ---- the session's PID namespace and its private /proc ----
13891
13892    /// A policy allowlisting `shell_allow` and nothing else.
13893    fn allow_only(shell_allow: &[&str]) -> CapabilityPolicy {
13894        CapabilityPolicy {
13895            shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
13896            ..CapabilityPolicy::default()
13897        }
13898    }
13899
13900    /// A `sealed` session's enforcement for `policy` from a live probe, or `None` (after a skip
13901    /// line naming `test`) on a host that does not reach `KernelSealed`.
13902    fn sealed_landlock_enforcement(
13903        test: &str,
13904        policy: &CapabilityPolicy,
13905    ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
13906        if crate::network_namespace::skip_without_egress_namespace(test) {
13907            return None;
13908        }
13909        let probe = HostProbe::probe();
13910        if probe.tier() != EnforcementTier::KernelSealed {
13911            eprintln!(
13912                "SKIP — PROVES NOTHING ABOUT SEALED ON THIS HOST: {test} needs a host that \
13913                 reaches KernelSealed; detected {:?}",
13914                probe.tier()
13915            );
13916            return None;
13917        }
13918        let runtime = tempfile::tempdir().unwrap();
13919        let enforcement = ShellEnforcement::resolve_declared(
13920            policy,
13921            murmur_artifact::ContainmentClass::Sealed,
13922            probe,
13923        )
13924        .unwrap()
13925        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13926        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
13927        Some((enforcement, runtime))
13928    }
13929
13930    /// Whether `enforcement` mounts a private `/proc`, printing a skip line naming `test` when
13931    /// this host's probe could not mount one.
13932    fn mounts_private_proc(test: &str, enforcement: &ShellEnforcement) -> bool {
13933        match enforcement.installed_proc_view() {
13934            crate::proc_view::ProcView::Private => true,
13935            crate::proc_view::ProcView::Host(reason) => {
13936                eprintln!(
13937                    "SKIP — PROVES NOTHING: {test} needs a host that mounts a private procfs; \
13938                     {}",
13939                    reason.describe()
13940                );
13941                false
13942            }
13943        }
13944    }
13945
13946    /// The script the scoped and sealed own-`/proc` tests run: the files `node`, `os.cpus()` and
13947    /// every runtime that reads its own process read, each through `/proc`.
13948    const OWN_PROC_SCRIPT: &str = "head -n 1 /proc/self/status; \
13949        head -n 1 /proc/thread-self/status; \
13950        head -n 1 /proc/stat; \
13951        n=0; while read -r line; do case $line in processor*) n=$((n+1));; esac; done \
13952        < /proc/cpuinfo; echo \"processors=$n\"; \
13953        head -n 1 /proc/meminfo; \
13954        while read -r _ point kind options _; do \
13955        if [ \"$point\" = /proc ]; then echo \"proc-mount: $kind $options\"; fi; \
13956        done < /proc/self/mounts";
13957
13958    fn assert_reads_own_proc(result: &crate::shell::ShellResult) {
13959        assert_eq!(result.exit_code, 0, "{}", result.stderr);
13960        assert!(
13961            !result.stderr.contains("Permission denied"),
13962            "{}",
13963            result.stderr
13964        );
13965        let lines: Vec<&str> = result.stdout.lines().collect();
13966        assert!(lines.len() >= 5, "{}", result.stdout);
13967        assert!(lines[0].starts_with("Name:"), "{}", result.stdout);
13968        assert!(lines[1].starts_with("Name:"), "{}", result.stdout);
13969        assert!(lines[2].starts_with("cpu "), "{}", result.stdout);
13970        let processors: u32 = lines[3]
13971            .strip_prefix("processors=")
13972            .and_then(|n| n.parse().ok())
13973            .unwrap_or_else(|| panic!("{}", result.stdout));
13974        assert!(processors > 0, "{}", result.stdout);
13975        assert!(lines[4].starts_with("MemTotal:"), "{}", result.stdout);
13976    }
13977
13978    /// A `scoped` command reads its own `/proc/self/*` and `/proc/thread-self/*`, and the
13979    /// kernel's `stat`, `cpuinfo` and `meminfo`, through the private `/proc` and its read rule.
13980    #[test]
13981    fn a_scoped_command_reads_its_own_proc() {
13982        let test = "a_scoped_command_reads_its_own_proc";
13983        let (_base, work, _outside) = work_and_outside();
13984        let policy = allow_only(&["bash", "cat", "head"]);
13985        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13986            return;
13987        };
13988        if !mounts_private_proc(test, &enforcement) {
13989            return;
13990        }
13991        let result = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
13992        enforcement.end_session();
13993        assert_reads_own_proc(&result);
13994    }
13995
13996    /// A process outside every capsule, which the test kills when it goes out of scope.
13997    struct HostSleeper(std::process::Child);
13998
13999    impl HostSleeper {
14000        fn start() -> Self {
14001            Self(
14002                std::process::Command::new("sleep")
14003                    .arg("300")
14004                    .spawn()
14005                    .unwrap(),
14006            )
14007        }
14008
14009        fn pid(&self) -> u32 {
14010            self.0.id()
14011        }
14012    }
14013
14014    impl Drop for HostSleeper {
14015        fn drop(&mut self) {
14016            let _ = self.0.kill();
14017            let _ = self.0.wait();
14018        }
14019    }
14020
14021    /// The script the scoped and sealed host-process tests run against a host process `pid`.
14022    fn host_process_script(pid: u32) -> String {
14023        format!("ls /proc; cat /proc/{pid}/cmdline; echo \"cat=$?\"")
14024    }
14025
14026    fn assert_sees_no_host_process(result: &crate::shell::ShellResult, host_pids: &[u32]) {
14027        assert_eq!(result.exit_code, 0, "{}", result.stderr);
14028        let entries: Vec<u32> = result
14029            .stdout
14030            .lines()
14031            .filter_map(|line| line.trim().parse().ok())
14032            .collect();
14033        assert!(!entries.is_empty(), "no process listed: {}", result.stdout);
14034        // The command's own tree: bash and the `ls` it forked. The holder is hidden.
14035        assert!(entries.len() <= 3, "{entries:?}");
14036        for pid in host_pids {
14037            assert!(!entries.contains(pid), "host pid {pid} listed: {entries:?}");
14038        }
14039        assert!(
14040            result.stderr.contains("No such file or directory"),
14041            "{}",
14042            result.stderr
14043        );
14044        assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
14045    }
14046
14047    /// A `scoped` command's `/proc` lists only its own tree. A host process the test started
14048    /// outside any capsule, and the test itself, are absent, and opening one's `cmdline` is
14049    /// `ENOENT`, not `EACCES`.
14050    #[test]
14051    fn a_scoped_command_cannot_see_host_processes() {
14052        let test = "a_scoped_command_cannot_see_host_processes";
14053        let (_base, work, _outside) = work_and_outside();
14054        let policy = allow_only(&["bash", "ls", "cat"]);
14055        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14056            return;
14057        };
14058        if !mounts_private_proc(test, &enforcement) {
14059            return;
14060        }
14061        let sleeper = HostSleeper::start();
14062        let result = run_bash_in(
14063            &work,
14064            &host_process_script(sleeper.pid()),
14065            &policy,
14066            &enforcement,
14067        );
14068        enforcement.end_session();
14069        assert_sees_no_host_process(&result, &[sleeper.pid(), std::process::id()]);
14070    }
14071
14072    /// The OpenSSL configuration file a Node build reads at startup.
14073    const NODE_OPENSSL_CONFIG: &str = "/etc/ssl/openssl.cnf";
14074
14075    /// `node` under `scoped` counts the host's CPUs and resolves `localhost` to loopback, with
14076    /// no `network.allow` and no grant on `/proc` or `/etc`.
14077    #[test]
14078    fn a_scoped_node_counts_cpus_and_resolves_localhost() {
14079        let test = "a_scoped_node_counts_cpus_and_resolves_localhost";
14080        if resolve_exec_allowlist(&["node".to_string()]).is_empty() {
14081            eprintln!("SKIP — PROVES NOTHING: {test} needs node on PATH");
14082            return;
14083        }
14084        let (_base, work, _outside) = work_and_outside();
14085        let mut policy = allow_only(&["node"]);
14086        // V8's address-space reservation; see `W-RUN-010`.
14087        policy.resources.memory_bytes = 64 * 1024 * 1024 * 1024;
14088        // Node reads OpenSSL's configuration at startup and exits 13 when it is refused, so it is
14089        // granted the one file, as the how-to grants it; nothing here grants `/proc` or `/etc/hosts`.
14090        if Path::new(NODE_OPENSSL_CONFIG).is_file() {
14091            policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
14092                binary: "node".to_string(),
14093                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
14094                    path: NODE_OPENSSL_CONFIG.to_string(),
14095                    list_dir: false,
14096                }],
14097            }];
14098        }
14099        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14100            return;
14101        };
14102        if !mounts_private_proc(test, &enforcement) {
14103            return;
14104        }
14105        let result = execute(
14106            "node",
14107            &[
14108                "-e",
14109                "console.log('cpus=' + require('os').cpus().length); \
14110                 require('dns').lookup('localhost', (error, address) => \
14111                 console.log(error ? 'lookup=' + error.code : 'lookup=' + address));",
14112            ],
14113            &work,
14114            &policy,
14115            &enforcement,
14116        );
14117        enforcement.end_session();
14118        assert_eq!(result.exit_code, 0, "{}", result.stderr);
14119        let cpus: u32 = result
14120            .stdout
14121            .lines()
14122            .find_map(|line| line.strip_prefix("cpus="))
14123            .and_then(|n| n.parse().ok())
14124            .unwrap_or_else(|| panic!("{}", result.stdout));
14125        assert!(cpus > 0, "{}", result.stdout);
14126        let address = result
14127            .stdout
14128            .lines()
14129            .find_map(|line| line.strip_prefix("lookup="))
14130            .unwrap_or_else(|| panic!("{}", result.stdout));
14131        assert!(
14132            address == "127.0.0.1" || address == "::1",
14133            "{}",
14134            result.stdout
14135        );
14136    }
14137
14138    /// A `scoped` command resolves `localhost` through `/etc/hosts`: both name-resolution
14139    /// files read, and the rest of `/etc` — `/etc/passwd`, `/etc/resolv.conf` — stays denied.
14140    #[test]
14141    fn a_scoped_command_resolves_localhost_through_etc_hosts() {
14142        let test = "a_scoped_command_resolves_localhost_through_etc_hosts";
14143        let (_base, work, _outside) = work_and_outside();
14144        let policy = allow_only(&["bash", "cat", "getent"]);
14145        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14146            return;
14147        };
14148        let result = run_bash_in(
14149            &work,
14150            "getent hosts localhost; echo \"getent=$?\"; \
14151             cat /etc/hosts > /dev/null; echo \"hosts=$?\"; \
14152             cat /etc/nsswitch.conf > /dev/null; echo \"nsswitch=$?\"; \
14153             cat /etc/passwd > /dev/null; echo \"passwd=$?\"; \
14154             cat /etc/resolv.conf > /dev/null; echo \"resolv=$?\"",
14155            &policy,
14156            &enforcement,
14157        );
14158        enforcement.end_session();
14159        let stdout = &result.stdout;
14160        assert!(
14161            stdout
14162                .lines()
14163                .any(|line| line.starts_with("127.0.0.1") || line.starts_with("::1")),
14164            "{stdout}"
14165        );
14166        for read in ["getent=0", "hosts=0", "nsswitch=0"] {
14167            assert!(stdout.lines().any(|line| line == read), "{read}: {stdout}");
14168        }
14169        for denied in ["passwd=1", "resolv=1"] {
14170            assert!(
14171                stdout.lines().any(|line| line == denied),
14172                "{denied}: {stdout}"
14173            );
14174        }
14175        assert!(
14176            result.stderr.contains("/etc/passwd: Permission denied"),
14177            "{}",
14178            result.stderr
14179        );
14180        assert!(
14181            result
14182                .stderr
14183                .contains("/etc/resolv.conf: Permission denied"),
14184            "{}",
14185            result.stderr
14186        );
14187    }
14188
14189    /// The exit code and the terminating signal the runtime sees are the command's, not its
14190    /// relay's, and a command's parent reads as `0` — outside its PID namespace.
14191    #[test]
14192    fn a_command_under_its_relay_keeps_its_exit_status_and_signal() {
14193        let test = "a_command_under_its_relay_keeps_its_exit_status_and_signal";
14194        let (_base, work, _outside) = work_and_outside();
14195        let policy = allow_only(&["bash"]);
14196        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14197            return;
14198        };
14199        let exited = run_bash_in(&work, "exit 7", &policy, &enforcement);
14200        let killed = run_bash_in(&work, "kill -KILL $$", &policy, &enforcement);
14201        let parent = run_bash_in(&work, "echo \"ppid=$PPID pid=$$\"", &policy, &enforcement);
14202        enforcement.end_session();
14203        assert_eq!(exited.exit_code, 7);
14204        assert_eq!(killed.exit_code, 128 + libc::SIGKILL);
14205        assert!(
14206            parent.stdout.starts_with("ppid=0 pid="),
14207            "{}",
14208            parent.stdout
14209        );
14210        let pid: i32 = parent
14211            .stdout
14212            .trim()
14213            .rsplit('=')
14214            .next()
14215            .unwrap()
14216            .parse()
14217            .unwrap();
14218        assert!(pid > 1 && pid < 100, "a namespace pid: {}", parent.stdout);
14219    }
14220
14221    /// The host pid of `namespace_pid` in `session`'s own PID namespace, polled briefly: the pid
14222    /// file is written before the process it names is guaranteed to be visible.
14223    ///
14224    /// Not `host_pid_in_session`, which looks in every session this test process holds and
14225    /// answers with the first match: tests run in parallel and number their processes alike, so
14226    /// that can be another test's process.
14227    fn host_pid(session: &ShellEnforcement, namespace_pid: i32) -> i32 {
14228        let join = session
14229            .session_netns
14230            .establish(
14231                &session.egress_tcp_ports,
14232                crate::egress_proxy::EgressPolicy::new(
14233                    session.network_allow_rules.clone(),
14234                    session.network_allow_ips.clone(),
14235                ),
14236                session.observed_held_ports(),
14237            )
14238            .expect("the session's namespace is established");
14239        let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
14240            .expect("the session's PID namespace handle is readable");
14241        let wanted = namespace_pid.to_string();
14242        for _ in 0..100 {
14243            let found = std::fs::read_dir("/proc")
14244                .expect("/proc is readable")
14245                .filter_map(Result::ok)
14246                .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
14247                .find(|pid| {
14248                    std::fs::read_link(format!("/proc/{pid}/ns/pid"))
14249                        .is_ok_and(|namespace| namespace == pid_ns)
14250                        && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
14251                            |status| {
14252                                status
14253                                    .lines()
14254                                    .find_map(|line| line.strip_prefix("NSpid:"))
14255                                    .and_then(|fields| fields.split_whitespace().last())
14256                                    == Some(wanted.as_str())
14257                            },
14258                        )
14259                });
14260            if let Some(pid) = found {
14261                return pid;
14262            }
14263            std::thread::sleep(std::time::Duration::from_millis(20));
14264        }
14265        panic!("no process of this session is pid {namespace_pid} in its namespace");
14266    }
14267
14268    /// A server a command backgrounds survives into the next command, which sees it in the
14269    /// same PID namespace, and is gone with the namespace's holder once the session ends.
14270    #[test]
14271    fn a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session() {
14272        let test =
14273            "a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session";
14274        let (_base, work, _outside) = work_and_outside();
14275        let policy = allow_only(&["bash", "sleep"]);
14276        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14277            return;
14278        };
14279        let started = run_bash_in(
14280            &work,
14281            "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
14282            &policy,
14283            &enforcement,
14284        );
14285        assert_eq!(started.exit_code, 0, "{}", started.stderr);
14286        let namespace_pid = pid_in(&work, "sleeper.pid");
14287        let sleeper = host_pid(&enforcement, namespace_pid);
14288        let holder = host_pid(&enforcement, 1);
14289        assert_ne!(sleeper, namespace_pid, "the pid file holds a namespace pid");
14290        let seen = run_bash_in(
14291            &work,
14292            "kill -0 \"$(< sleeper.pid)\" && echo alive",
14293            &policy,
14294            &enforcement,
14295        );
14296        assert_eq!(seen.stdout.trim(), "alive", "{}", seen.stderr);
14297        assert!(process_exists(sleeper));
14298        enforcement.end_session();
14299        let wait = SESSION_END_KILL_WAIT + std::time::Duration::from_secs(3);
14300        assert!(
14301            process_gone_within(holder, wait),
14302            "holder {holder} outlived the session"
14303        );
14304        assert!(
14305            process_gone_within(sleeper, wait),
14306            "sleeper {sleeper} outlived the session"
14307        );
14308    }
14309
14310    /// A session dropped without `end_session` closes its holder's lifeline, which ends the
14311    /// holder and every process in its PID namespace.
14312    #[test]
14313    fn a_dropped_session_ends_its_holder_and_its_processes() {
14314        let test = "a_dropped_session_ends_its_holder_and_its_processes";
14315        let (_base, work, _outside) = work_and_outside();
14316        let policy = allow_only(&["bash", "sleep"]);
14317        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14318            return;
14319        };
14320        let started = run_bash_in(
14321            &work,
14322            "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
14323            &policy,
14324            &enforcement,
14325        );
14326        assert_eq!(started.exit_code, 0, "{}", started.stderr);
14327        let sleeper = host_pid(&enforcement, pid_in(&work, "sleeper.pid"));
14328        let holder = host_pid(&enforcement, 1);
14329        drop(enforcement);
14330        assert!(
14331            process_gone_within(holder, SESSION_END_KILL_WAIT),
14332            "holder {holder} outlived its dropped session"
14333        );
14334        assert!(
14335            process_gone_within(sleeper, SESSION_END_KILL_WAIT),
14336            "sleeper {sleeper} outlived its dropped session"
14337        );
14338    }
14339
14340    /// `Child::kill()` reaches the relay, the process `Child` refers to, and the command dies
14341    /// with it through `PR_SET_PDEATHSIG`; `wait` then reports the kill.
14342    #[test]
14343    fn killing_the_relay_ends_the_command() {
14344        use std::os::unix::process::{CommandExt, ExitStatusExt};
14345        let test = "killing_the_relay_ends_the_command";
14346        let (_base, work, _outside) = work_and_outside();
14347        let policy = allow_only(&["bash", "sleep"]);
14348        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14349            return;
14350        };
14351        let bash = resolve_exec_allowlist(&["bash".to_string()])
14352            .into_iter()
14353            .next()
14354            .unwrap();
14355        let mut command = std::process::Command::new(bash);
14356        command
14357            .args(["-c", "echo $$ > command.pid; exec sleep 300"])
14358            .current_dir(&work)
14359            .process_group(0);
14360        let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
14361        let mut relay = command.spawn().unwrap();
14362        let pid_file = work.join("command.pid");
14363        for _ in 0..200 {
14364            if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
14365                break;
14366            }
14367            std::thread::sleep(std::time::Duration::from_millis(20));
14368        }
14369        let command_pid = host_pid(&enforcement, pid_in(&work, "command.pid"));
14370        assert_ne!(
14371            command_pid,
14372            relay.id() as i32,
14373            "the command is not its relay"
14374        );
14375        relay.kill().unwrap();
14376        let status = relay.wait().unwrap();
14377        assert_eq!(status.signal(), Some(libc::SIGKILL));
14378        assert!(
14379            process_gone_within(command_pid, std::time::Duration::from_secs(5)),
14380            "command {command_pid} outlived its relay"
14381        );
14382        enforcement.end_session();
14383    }
14384
14385    /// A process-group `SIGTERM`, which a detached `stop` sends, reaches the command past its
14386    /// relay, which blocks every signal, and the relay then leaves the command's terminating
14387    /// signal as its own status.
14388    #[test]
14389    fn a_process_group_sigterm_reaches_the_command_past_its_relay() {
14390        use std::os::unix::process::{CommandExt, ExitStatusExt};
14391        let test = "a_process_group_sigterm_reaches_the_command_past_its_relay";
14392        let (_base, work, _outside) = work_and_outside();
14393        let policy = allow_only(&["bash", "sleep"]);
14394        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14395            return;
14396        };
14397        let bash = resolve_exec_allowlist(&["bash".to_string()])
14398            .into_iter()
14399            .next()
14400            .unwrap();
14401        let mut command = std::process::Command::new(bash);
14402        command
14403            .args(["-c", "echo $$ > command.pid; exec sleep 300"])
14404            .current_dir(&work)
14405            .process_group(0);
14406        let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
14407        let mut relay = command.spawn().unwrap();
14408        let pid_file = work.join("command.pid");
14409        for _ in 0..200 {
14410            if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
14411                break;
14412            }
14413            std::thread::sleep(std::time::Duration::from_millis(20));
14414        }
14415        let command_pid = host_pid(&enforcement, pid_in(&work, "command.pid"));
14416        let sent_at = std::time::Instant::now();
14417        assert!(crate::formation_launch::signal_group(
14418            i64::from(relay.id()),
14419            libc::SIGTERM
14420        ));
14421        let status = relay.wait().unwrap();
14422        assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
14423        assert!(
14424            sent_at.elapsed() < std::time::Duration::from_secs(5),
14425            "the command took {:?} to end",
14426            sent_at.elapsed()
14427        );
14428        assert!(
14429            process_gone_within(command_pid, std::time::Duration::from_secs(5)),
14430            "command {command_pid} outlived the group SIGTERM"
14431        );
14432        enforcement.end_session();
14433    }
14434
14435    /// The own-`/proc` and host-process scripts under `sealed`, where the composed root binds the
14436    /// private `/proc` the command mounted before it.
14437    #[test]
14438    fn a_sealed_command_reads_its_own_proc_and_sees_no_host_process() {
14439        let test = "a_sealed_command_reads_its_own_proc_and_sees_no_host_process";
14440        let (_base, work, _outside) = work_and_outside();
14441        let policy = allow_only(&["bash", "cat", "head", "ls"]);
14442        let Some((enforcement, _runtime)) = sealed_landlock_enforcement(test, &policy) else {
14443            return;
14444        };
14445        if !mounts_private_proc(test, &enforcement) {
14446            return;
14447        }
14448        let own = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
14449        let sleeper = HostSleeper::start();
14450        let host = run_bash_in(
14451            &work,
14452            &host_process_script(sleeper.pid()),
14453            &policy,
14454            &enforcement,
14455        );
14456        enforcement.end_session();
14457        assert_reads_own_proc(&own);
14458        assert_sees_no_host_process(&host, &[sleeper.pid(), std::process::id()]);
14459    }
14460
14461    /// A session whose probe could not mount a private `/proc` runs its commands with the
14462    /// host's, where every open beneath `/proc` is refused as before, and says why.
14463    #[test]
14464    fn a_host_proc_view_keeps_proc_denied_and_says_why() {
14465        let test = "a_host_proc_view_keeps_proc_denied_and_says_why";
14466        let (_base, work, _outside) = work_and_outside();
14467        let policy = allow_only(&["bash", "cat", "ls"]);
14468        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14469            return;
14470        };
14471        let refused =
14472            crate::proc_view::ProcView::Host(crate::proc_view::HostProcReason::MountRefused(
14473                "mounting proc over /proc failed with EPERM".to_string(),
14474            ));
14475        let enforcement = enforcement.with_proc_view(refused.clone());
14476        assert_eq!(enforcement.installed_proc_view(), refused);
14477        let result = run_bash_in(
14478            &work,
14479            "cat /proc/self/status; echo \"cat=$?\"; ls /proc/1 >/dev/null; echo \"ls=$?\"",
14480            &policy,
14481            &enforcement,
14482        );
14483        enforcement.end_session();
14484        assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
14485        assert!(
14486            result
14487                .stderr
14488                .contains("/proc/self/status: Permission denied"),
14489            "{}",
14490            result.stderr
14491        );
14492        let report = crate::proc_view::process_view_report(&refused);
14493        assert!(!report.private);
14494        assert!(report
14495            .not_private_because
14496            .is_some_and(|reason| reason.contains("failed with EPERM")),);
14497    }
14498
14499    /// A private-`/proc` mount that fails at spawn fails the spawn, naming the step and its
14500    /// `errno` under the fixed prefix; nothing runs with the host's `/proc` instead.
14501    #[test]
14502    fn a_private_proc_mount_that_fails_at_spawn_fails_the_spawn() {
14503        let test = "a_private_proc_mount_that_fails_at_spawn_fails_the_spawn";
14504        let (_base, work, _outside) = work_and_outside();
14505        let policy = allow_only(&["bash"]);
14506        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14507            return;
14508        };
14509        if !mounts_private_proc(test, &enforcement) {
14510            return;
14511        }
14512        let _armed = ForcePrivateProcFailureGuard::new();
14513        let error = crate::shell::execute_shell(
14514            "bash",
14515            &["-c", "echo ran > ran.txt"],
14516            &[],
14517            &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
14518            &policy,
14519            &enforcement,
14520        )
14521        .expect_err("a failed private /proc mount must fail the spawn");
14522        enforcement.end_session();
14523        let error = error.to_string();
14524        assert!(
14525            error.contains(crate::proc_view::PRIVATE_PROC_FAILURE_PREFIX),
14526            "{error}"
14527        );
14528        assert!(
14529            error.contains("mounting proc over /proc failed: EPERM"),
14530            "{error}"
14531        );
14532        assert!(!work.join("ran.txt").exists());
14533    }
14534
14535    /// Every name-resolution file `scoped` grants is one `sealed` already grants, and the
14536    /// resolver issues them read-only, unlistable and unexecutable on `KernelFull` alone.
14537    #[test]
14538    fn scoped_name_resolution_grants_are_a_read_only_subset_of_sealed_etc() {
14539        for path in SCOPED_NAME_RESOLUTION_PATHS {
14540            assert!(
14541                crate::sealed::SEALED_ETC_PATHS
14542                    .iter()
14543                    .any(|entry| entry.path == *path && !entry.directory),
14544                "{path} is not a SEALED_ETC_PATHS file"
14545            );
14546        }
14547        let grants = resolve_scoped_name_resolution_landlock_grants(EnforcementTier::KernelFull);
14548        assert_eq!(
14549            grants
14550                .iter()
14551                .map(|grant| grant.path.clone())
14552                .collect::<Vec<_>>(),
14553            vec![
14554                PathBuf::from("/etc/hosts"),
14555                PathBuf::from("/etc/nsswitch.conf")
14556            ]
14557        );
14558        assert!(grants
14559            .iter()
14560            .all(|grant| !grant.list_dir && !grant.executable));
14561        for tier in [
14562            EnforcementTier::KernelSealed,
14563            EnforcementTier::KernelSeccompOnly,
14564            EnforcementTier::EnvironmentOnly,
14565            EnforcementTier::Unconfined,
14566        ] {
14567            assert!(resolve_scoped_name_resolution_landlock_grants(tier).is_empty());
14568        }
14569    }
14570
14571    /// A creator that cannot create the PID namespace is refused with `E-CAP-005` naming that
14572    /// step, at the session's first command, and nothing runs.
14573    #[test]
14574    fn a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005() {
14575        let test = "a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005";
14576        let (_base, work, _outside) = work_and_outside();
14577        let policy = allow_only(&["bash"]);
14578        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14579            return;
14580        };
14581        let _armed = crate::network_namespace::ForcePidNamespaceFailure::arm();
14582        let error = crate::shell::execute_shell(
14583            "bash",
14584            &["-c", "echo ran > ran.txt"],
14585            &[],
14586            &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
14587            &policy,
14588            &enforcement,
14589        )
14590        .expect_err("a session without its PID namespace must not run");
14591        enforcement.end_session();
14592        let error = error.to_string();
14593        assert!(error.contains("E-CAP-005"), "{error}");
14594        assert!(
14595            error
14596                .contains("unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID) failed with EPERM"),
14597            "{error}"
14598        );
14599        assert!(error.contains("CLONE_NEWPID"), "{error}");
14600        assert!(!work.join("ran.txt").exists());
14601    }
14602
14603    /// The trust-store grant reaches the certificates and nothing beside them: not the directory
14604    /// above, its OpenSSL configuration or its private keys, and nothing is writable.
14605    #[test]
14606    fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
14607        let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
14608        let (_base, work, _outside) = work_and_outside();
14609        let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
14610        for entry in &policy.shell_trust_store {
14611            for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
14612                assert_ne!(entry.path, Path::new(broad));
14613            }
14614            assert!(
14615                !entry.path.components().any(|c| c.as_os_str() == "private"),
14616                "{entry:?}"
14617            );
14618        }
14619        assert!(resolve_trust_store_grants(&policy.shell_trust_store)
14620            .iter()
14621            .all(|grant| !grant.executable));
14622        if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
14623            eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
14624            return;
14625        }
14626        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14627            return;
14628        };
14629
14630        let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
14631        assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
14632        let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
14633        assert_ne!(above.exit_code, 0);
14634        assert!(
14635            above.stderr.contains("Permission denied"),
14636            "{}",
14637            above.stderr
14638        );
14639        if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
14640            let config = execute(
14641                "cat",
14642                &["/etc/ssl/openssl.cnf"],
14643                &work,
14644                &policy,
14645                &enforcement,
14646            );
14647            assert_ne!(config.exit_code, 0);
14648            assert!(
14649                config.stderr.contains("Permission denied"),
14650                "{}",
14651                config.stderr
14652            );
14653        }
14654        let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
14655        assert_ne!(private.exit_code, 0);
14656        let probe = Path::new("/etc/ssl/certs/murmur-probe");
14657        let written = execute(
14658            "bash",
14659            &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
14660            &work,
14661            &policy,
14662            &enforcement,
14663        );
14664        enforcement.end_session();
14665        assert_ne!(written.exit_code, 0);
14666        assert!(std::fs::symlink_metadata(probe).is_err());
14667    }
14668
14669    /// A `scoped` command cannot write into `~/.murmur/host_facts`: with the home outside the
14670    /// workdir, a shell redirection there is refused and leaves no file.
14671    #[test]
14672    fn a_scoped_command_cannot_write_the_host_facts_cache() {
14673        let test = "a_scoped_command_cannot_write_the_host_facts_cache";
14674        let home = home_tempdir();
14675        let workdir = home_tempdir();
14676        let cache = home.path().join(".murmur").join(
14677            crate::murmur_home::HomeEntryKind::HostFacts
14678                .file_name()
14679                .unwrap(),
14680        );
14681        crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
14682        crate::murmur_home::hold_private_dir(&cache).unwrap();
14683        let policy = coreutils_policy(&[]);
14684        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14685            return;
14686        };
14687        let probe = cache.join("probe");
14688        let written = run_bash_in(
14689            workdir.path(),
14690            &format!("printf x > '{}'", probe.display()),
14691            &policy,
14692            &enforcement,
14693        );
14694        enforcement.end_session();
14695        assert_ne!(written.exit_code, 0);
14696        assert!(
14697            written.stderr.contains("Permission denied"),
14698            "{}",
14699            written.stderr
14700        );
14701        assert!(std::fs::symlink_metadata(&probe).is_err());
14702    }
14703
14704    #[test]
14705    fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
14706        let paths = [
14707            crate::trust_store::TrustStorePath {
14708                path: PathBuf::from("/etc/ssl/certs"),
14709                directory: true,
14710            },
14711            crate::trust_store::TrustStorePath {
14712                path: PathBuf::from("/etc/pki/tls/cacert.pem"),
14713                directory: false,
14714            },
14715        ];
14716        assert_eq!(
14717            resolve_trust_store_grants(&paths),
14718            vec![
14719                LandlockGrant {
14720                    path: PathBuf::from("/etc/ssl/certs"),
14721                    list_dir: true,
14722                    executable: false,
14723                },
14724                LandlockGrant {
14725                    path: PathBuf::from("/etc/pki/tls/cacert.pem"),
14726                    list_dir: false,
14727                    executable: false,
14728                },
14729            ]
14730        );
14731        assert!(resolve_trust_store_grants(&[]).is_empty());
14732    }
14733
14734    /// `ShellEnforcement::resolve` adds exactly the policy's trust-store grants on both Landlock
14735    /// tiers, whatever the host, and leaves the sealed `/etc` grants in place.
14736    #[test]
14737    fn shell_enforcement_resolve_adds_the_trust_store() {
14738        use murmur_artifact::ContainmentClass;
14739        let store = vec![
14740            crate::trust_store::TrustStorePath {
14741                path: PathBuf::from("/murmur-test/certs"),
14742                directory: true,
14743            },
14744            crate::trust_store::TrustStorePath {
14745                path: PathBuf::from("/murmur-test/bundle.pem"),
14746                directory: false,
14747            },
14748        ];
14749        let expected = resolve_trust_store_grants(&store);
14750        let with_store = CapabilityPolicy {
14751            shell_trust_store: store.clone(),
14752            ..CapabilityPolicy::default()
14753        };
14754        let without = CapabilityPolicy::default();
14755        for (tier, declared) in [
14756            (EnforcementTier::KernelFull, ContainmentClass::Scoped),
14757            (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
14758        ] {
14759            let decision =
14760                crate::containment::ContainmentDecision::decide(tier, declared, false, false);
14761            let granted = |policy: &CapabilityPolicy| {
14762                let enforcement =
14763                    ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
14764                assert_eq!(enforcement.tier, tier);
14765                enforcement.landlock_grants
14766            };
14767            let with_grants = granted(&with_store);
14768            let without_grants = granted(&without);
14769            for grant in &expected {
14770                assert_eq!(
14771                    with_grants.iter().filter(|g| *g == grant).count(),
14772                    1,
14773                    "{tier:?}: {grant:?}"
14774                );
14775            }
14776            assert!(without_grants
14777                .iter()
14778                .all(|grant| !grant.path.starts_with("/murmur-test")));
14779            assert_eq!(with_grants.len(), without_grants.len() + expected.len());
14780            let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
14781            assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
14782            for grant in &sealed_etc {
14783                assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
14784            }
14785        }
14786    }
14787
14788    /// An allowlisted `python3` with no `interpreter_runtime` block imports its standard library
14789    /// and a C extension needing a library outside its own closure under `scoped`, while a file
14790    /// outside its runtime and the directory above its stdlib stay denied.
14791    #[test]
14792    fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
14793        let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
14794        if find_on_path("python3").is_none() {
14795            println!("[SKIP] {test}: no python3 on PATH");
14796            return;
14797        }
14798        let (_base, work, outside) = work_and_outside();
14799        let policy = derived_interpreter_policy("python3", &work);
14800        let stdlib = assert_derives_stdlib_and_sqlite(&policy);
14801        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14802            return;
14803        };
14804
14805        let imported = execute(
14806            "python3",
14807            &["-c", "import json, sqlite3, venv; print('imported')"],
14808            &work,
14809            &policy,
14810            &enforcement,
14811        );
14812        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
14813        assert_eq!(imported.stdout.trim(), "imported");
14814
14815        let secret = outside.join("secret.txt");
14816        std::fs::write(&secret, "secret").unwrap();
14817        let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
14818        let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
14819        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
14820        assert_eq!(
14821            denied.stdout.lines().collect::<Vec<_>>(),
14822            vec!["open PermissionError", "listdir PermissionError"]
14823        );
14824    }
14825
14826    /// The same interpreter under `sealed`: it imports what it imported under `scoped`, and the
14827    /// file outside its runtime is neither readable nor present in the composed root.
14828    #[test]
14829    fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
14830        let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
14831        if crate::network_namespace::skip_without_egress_namespace(test) {
14832            return;
14833        }
14834        if find_on_path("python3").is_none() {
14835            println!("[SKIP] {test}: no python3 on PATH");
14836            return;
14837        }
14838        let probe = HostProbe::probe();
14839        if probe.tier() != EnforcementTier::KernelSealed {
14840            eprintln!(
14841                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
14842                 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
14843                probe.tier()
14844            );
14845            return;
14846        }
14847        let (_base, work, outside) = work_and_outside();
14848        let policy = derived_interpreter_policy("python3", &work);
14849        assert_derives_stdlib_and_sqlite(&policy);
14850        let runtime = tempfile::tempdir().unwrap();
14851        let enforcement = ShellEnforcement::resolve_declared(
14852            &policy,
14853            murmur_artifact::ContainmentClass::Sealed,
14854            probe,
14855        )
14856        .expect("a sealed-capable host resolves sealed enforcement")
14857        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
14858        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
14859
14860        let imported = execute(
14861            "python3",
14862            &["-c", "import json, sqlite3, venv; print('imported')"],
14863            &work,
14864            &policy,
14865            &enforcement,
14866        );
14867        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
14868        assert_eq!(imported.stdout.trim(), "imported");
14869
14870        let secret = outside.join("secret.txt");
14871        std::fs::write(&secret, "secret").unwrap();
14872        let program = format!(
14873            "try:\n    open({:?}).read(); print('allowed')\nexcept Exception as error:\n    \
14874             print(type(error).__name__)\n",
14875            secret.to_string_lossy()
14876        );
14877        let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
14878        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
14879        let outcome = denied.stdout.trim();
14880        assert!(
14881            outcome == "PermissionError" || outcome == "FileNotFoundError",
14882            "{outcome}"
14883        );
14884    }
14885
14886    /// An allowlisted `perl` with no `interpreter_runtime` block loads `POSIX`, an XS module, out
14887    /// of its own `@INC` under `scoped`.
14888    #[test]
14889    fn a_scoped_perl_loads_posix_from_its_inc() {
14890        let test = "a_scoped_perl_loads_posix_from_its_inc";
14891        if find_on_path("perl").is_none() {
14892            println!("[SKIP] {test}: no perl on PATH");
14893            return;
14894        }
14895        let (_base, work, _outside) = work_and_outside();
14896        let policy = derived_interpreter_policy("perl", &work);
14897        assert!(
14898            !policy.shell_derived_interpreter_runtime.is_empty(),
14899            "perl was not recognised or reported nothing"
14900        );
14901        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14902            return;
14903        };
14904        let result = execute(
14905            "perl",
14906            &["-MPOSIX", "-e", "print qq{loaded\n}"],
14907            &work,
14908            &policy,
14909            &enforcement,
14910        );
14911        assert_eq!(result.exit_code, 0, "{}", result.stderr);
14912        assert_eq!(result.stdout.trim(), "loaded");
14913    }
14914
14915    // ---------------------------------------------------------------- host paths
14916
14917    /// The rights each host-path rule is built with: `ReadFile | ReadDir` for `read`, the
14918    /// workdir's right-set without `Execute` for `read-write`, and `Execute` never.
14919    #[test]
14920    fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
14921        use landlock::AccessFs;
14922        assert_eq!(
14923            linux_enforce::host_path_access_rights(false),
14924            AccessFs::ReadFile | AccessFs::ReadDir
14925        );
14926        assert_eq!(
14927            linux_enforce::host_path_access_rights(true),
14928            linux_enforce::workdir_access_rights(false)
14929        );
14930        for read_write in [false, true] {
14931            assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
14932        }
14933    }
14934
14935    /// `ro/` holding `in.txt`, `rw/` and `outside/` holding `secret.txt`, under `HOME` (never
14936    /// `/tmp`, which a host path may not be under), with the two host paths a test lends.
14937    struct HostPathDirs {
14938        _base: tempfile::TempDir,
14939        ro: PathBuf,
14940        rw: PathBuf,
14941        outside: PathBuf,
14942    }
14943
14944    impl HostPathDirs {
14945        fn new() -> Self {
14946            let base = home_tempdir();
14947            let root = std::fs::canonicalize(base.path()).unwrap();
14948            let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
14949            for dir in [&ro, &rw, &outside] {
14950                std::fs::create_dir(dir).unwrap();
14951            }
14952            std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
14953            std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
14954            Self {
14955                _base: base,
14956                ro,
14957                rw,
14958                outside,
14959            }
14960        }
14961
14962        fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
14963            use crate::host_paths::{HostPath, HostPathAccess};
14964            vec![
14965                HostPath {
14966                    declared: self.ro.display().to_string(),
14967                    path: self.ro.clone(),
14968                    access: HostPathAccess::Read,
14969                    credential_locations: Vec::new(),
14970                },
14971                HostPath {
14972                    declared: self.rw.display().to_string(),
14973                    path: self.rw.clone(),
14974                    access: HostPathAccess::ReadWrite,
14975                    credential_locations: Vec::new(),
14976                },
14977            ]
14978        }
14979
14980        /// Four builtin-only commands — read `ro/`, write `ro/`, write `rw/`, read `outside/` — run
14981        /// on `enforcement`, each asserted.
14982        fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
14983            let workdir = tempfile::tempdir().unwrap();
14984            let read = run_bash_in(
14985                workdir.path(),
14986                &format!(
14987                    "read -r line < '{}/in.txt' && echo \"$line\"",
14988                    self.ro.display()
14989                ),
14990                policy,
14991                enforcement,
14992            );
14993            assert_eq!(read.exit_code, 0, "{}", read.stderr);
14994            assert!(
14995                read.stdout.lines().any(|line| line == "lent"),
14996                "{}",
14997                read.stdout
14998            );
14999
15000            let write_ro = run_bash_in(
15001                workdir.path(),
15002                &format!("echo x > '{}/new'", self.ro.display()),
15003                policy,
15004                enforcement,
15005            );
15006            assert_ne!(write_ro.exit_code, 0);
15007            assert!(
15008                write_ro.stderr.contains("Permission denied")
15009                    || write_ro.stderr.contains("Read-only file system"),
15010                "{}",
15011                write_ro.stderr
15012            );
15013            assert!(!self.ro.join("new").exists());
15014
15015            let write_rw = run_bash_in(
15016                workdir.path(),
15017                &format!("echo written > '{}/new'", self.rw.display()),
15018                policy,
15019                enforcement,
15020            );
15021            assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
15022            assert_eq!(
15023                std::fs::read_to_string(self.rw.join("new")).unwrap(),
15024                "written\n"
15025            );
15026
15027            let outside = run_bash_in(
15028                workdir.path(),
15029                &format!("read -r line < '{}/secret.txt'", self.outside.display()),
15030                policy,
15031                enforcement,
15032            );
15033            assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
15034        }
15035    }
15036
15037    /// The enforcement a capsule declaring `class` gets here with `host_paths`, holding a session's
15038    /// directories, or `None` after a skip line when this host does not reach `tier`.
15039    fn host_path_enforcement(
15040        test: &str,
15041        policy: &CapabilityPolicy,
15042        class: murmur_artifact::ContainmentClass,
15043        tier: EnforcementTier,
15044    ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
15045        if crate::network_namespace::skip_without_egress_namespace(test) {
15046            return None;
15047        }
15048        let probe = HostProbe::probe();
15049        let reaches = match tier {
15050            EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
15051            _ => matches!(
15052                probe.tier(),
15053                EnforcementTier::KernelFull | EnforcementTier::KernelSealed
15054            ),
15055        };
15056        if !reaches {
15057            eprintln!(
15058                "SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
15059                probe.tier()
15060            );
15061            return None;
15062        }
15063        let runtime = home_tempdir();
15064        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
15065            .expect("the enforcement resolves")
15066            .with_runtime_dir(test_runtime_dir(runtime.path()), None);
15067        assert_eq!(enforcement.tier, tier);
15068        eprintln!("{test}: tier {tier:?}");
15069        Some((enforcement, runtime))
15070    }
15071
15072    /// [`ShellEnforcement::resolve`] for a capsule declaring `class` on a host of `tier`, from no
15073    /// probe: a resolution test that spawns nothing.
15074    fn resolve_on_tier(
15075        policy: &CapabilityPolicy,
15076        tier: EnforcementTier,
15077        class: murmur_artifact::ContainmentClass,
15078    ) -> Result<ShellEnforcement, String> {
15079        let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
15080        let probe = HostProbe::from_parts(
15081            None,
15082            crate::namespace_probe::NamespaceReading {
15083                network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
15084                    "not probed".to_string(),
15085                )),
15086                mount: crate::namespace_probe::MountAttempt::NotAttempted,
15087                sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
15088                private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
15089                userns_grant: crate::sealed::UsernsGrant::Withheld,
15090                attachment: crate::sealed::ProfileAttachment::NotReported,
15091                profile_gap: crate::sealed::ProfileGap::Undetermined,
15092            },
15093        );
15094        ShellEnforcement::resolve(policy, &decision, probe)
15095    }
15096
15097    #[test]
15098    fn host_paths_on_kernel_full_read_write_and_outside() {
15099        const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
15100        let dirs = HostPathDirs::new();
15101        let policy = CapabilityPolicy {
15102            shell_allow: vec!["bash".to_string()],
15103            host_paths: dirs.host_paths(),
15104            ..CapabilityPolicy::default()
15105        };
15106        let Some((enforcement, _runtime)) = host_path_enforcement(
15107            TEST,
15108            &policy,
15109            murmur_artifact::ContainmentClass::Scoped,
15110            EnforcementTier::KernelFull,
15111        ) else {
15112            return;
15113        };
15114        dirs.assert_reach(&enforcement, &policy);
15115    }
15116
15117    #[test]
15118    fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
15119        const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
15120        let dirs = HostPathDirs::new();
15121        let policy = CapabilityPolicy {
15122            shell_allow: vec!["bash".to_string()],
15123            host_paths: dirs.host_paths(),
15124            ..CapabilityPolicy::default()
15125        };
15126        let Some((enforcement, _runtime)) = host_path_enforcement(
15127            TEST,
15128            &policy,
15129            murmur_artifact::ContainmentClass::Sealed,
15130            EnforcementTier::KernelSealed,
15131        ) else {
15132            return;
15133        };
15134        dirs.assert_reach(&enforcement, &policy);
15135        // Outside is not denied but absent: the composed root does not hold it.
15136        let workdir = tempfile::tempdir().unwrap();
15137        let absent = run_bash_in(
15138            workdir.path(),
15139            &format!(
15140                "[ -e '{}/secret.txt' ] && echo present || echo absent",
15141                dirs.outside.display()
15142            ),
15143            &policy,
15144            &enforcement,
15145        );
15146        assert!(
15147            absent.stdout.lines().any(|line| line == "absent"),
15148            "{}",
15149            absent.stdout
15150        );
15151    }
15152
15153    /// A read-write host path holding a folder the session may run from: refused when resolving
15154    /// a `KernelFull` enforcement, and on `KernelSealed` that folder is bound read-only on top,
15155    /// so the capsule cannot rewrite what it runs while the rest of the entry stays writable.
15156    #[test]
15157    fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
15158        const TEST: &str =
15159            "a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
15160        let base = home_tempdir();
15161        let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
15162        let bin = tools.join("bin");
15163        std::fs::create_dir_all(&bin).unwrap();
15164        std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
15165        let policy = CapabilityPolicy {
15166            shell_allow: vec!["bash".to_string()],
15167            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
15168                binary: "bash".to_string(),
15169                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
15170                    path: bin.display().to_string(),
15171                    list_dir: true,
15172                }],
15173            }],
15174            host_paths: vec![crate::host_paths::HostPath {
15175                declared: tools.display().to_string(),
15176                path: tools.clone(),
15177                access: crate::host_paths::HostPathAccess::ReadWrite,
15178                credential_locations: Vec::new(),
15179            }],
15180            ..CapabilityPolicy::default()
15181        };
15182        let error = resolve_on_tier(
15183            &policy,
15184            EnforcementTier::KernelFull,
15185            murmur_artifact::ContainmentClass::Scoped,
15186        )
15187        .expect_err("scoped refuses a read-write host path over an execute root");
15188        assert!(error.contains(&bin.display().to_string()), "{error}");
15189
15190        let Some((enforcement, _runtime)) = host_path_enforcement(
15191            TEST,
15192            &policy,
15193            murmur_artifact::ContainmentClass::Sealed,
15194            EnforcementTier::KernelSealed,
15195        ) else {
15196            return;
15197        };
15198        assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
15199        let workdir = tempfile::tempdir().unwrap();
15200        let rewrite = run_bash_in(
15201            workdir.path(),
15202            &format!("echo x > '{}/tool'", bin.display()),
15203            &policy,
15204            &enforcement,
15205        );
15206        assert_ne!(rewrite.exit_code, 0);
15207        assert!(
15208            rewrite.stderr.contains("Read-only file system"),
15209            "{}",
15210            rewrite.stderr
15211        );
15212        let add = run_bash_in(
15213            workdir.path(),
15214            &format!("echo x > '{}/new'", bin.display()),
15215            &policy,
15216            &enforcement,
15217        );
15218        assert_ne!(add.exit_code, 0);
15219        assert!(
15220            add.stderr.contains("Read-only file system"),
15221            "{}",
15222            add.stderr
15223        );
15224        assert_eq!(
15225            std::fs::read_to_string(bin.join("tool")).unwrap(),
15226            "#!/bin/bash\necho tool ran\n"
15227        );
15228        assert!(!bin.join("new").exists());
15229        let elsewhere = run_bash_in(
15230            workdir.path(),
15231            &format!("echo kept > '{}/notes'", tools.display()),
15232            &policy,
15233            &enforcement,
15234        );
15235        assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
15236        assert_eq!(
15237            std::fs::read_to_string(tools.join("notes")).unwrap(),
15238            "kept\n"
15239        );
15240    }
15241
15242    /// A host path that can no longer be opened fails the spawn naming it, on both Landlock tiers.
15243    #[test]
15244    fn a_vanished_host_path_fails_the_spawn_naming_it() {
15245        let base = home_tempdir();
15246        let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
15247        std::fs::create_dir(&gone).unwrap();
15248        let host_paths = vec![crate::host_paths::HostPath {
15249            declared: gone.display().to_string(),
15250            path: gone.clone(),
15251            access: crate::host_paths::HostPathAccess::Read,
15252            credential_locations: Vec::new(),
15253        }];
15254        std::fs::remove_dir(&gone).unwrap();
15255        let workdir = tempfile::tempdir().unwrap();
15256        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
15257            let enforcement = ShellEnforcement {
15258                tier,
15259                host_paths: host_paths.clone(),
15260                ..host_bounding_base().with_test_session_dirs()
15261            };
15262            let mut command = std::process::Command::new("bash");
15263            let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
15264                panic!("{tier:?}: a vanished host path must fail the spawn");
15265            };
15266            assert!(
15267                error.contains(&gone.display().to_string()),
15268                "{tier:?}: {error}"
15269            );
15270        }
15271    }
15272
15273    /// No host path, no change: no rule, no bind, no base restriction.
15274    #[test]
15275    fn no_host_paths_add_nothing_to_the_enforcement() {
15276        let policy = CapabilityPolicy {
15277            shell_allow: vec!["bash".to_string()],
15278            ..CapabilityPolicy::default()
15279        };
15280        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
15281            let class = if tier == EnforcementTier::KernelSealed {
15282                murmur_artifact::ContainmentClass::Sealed
15283            } else {
15284                murmur_artifact::ContainmentClass::Scoped
15285            };
15286            let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
15287            assert_eq!(enforcement.tier, tier);
15288            assert!(enforcement.host_paths.is_empty());
15289            assert!(enforcement.host_path_read_only_binds.is_empty());
15290        }
15291    }
15292}
15293
15294// Linux only: the closure the test checks is an ELF reading.
15295#[cfg(all(test, target_os = "linux"))]
15296mod guest_worker_enforcement_tests {
15297    use super::*;
15298
15299    fn session(tier: EnforcementTier) -> ShellEnforcement {
15300        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
15301        enforcement.tier = tier;
15302        enforcement.workdir_exec = true;
15303        enforcement.landlock_grants = vec![LandlockGrant {
15304            path: PathBuf::from("/usr/bin/bash"),
15305            list_dir: false,
15306            executable: true,
15307        }];
15308        enforcement.sealed_bind_dirs = vec![PathBuf::from("/usr/bin")];
15309        enforcement.host_paths = vec![crate::host_paths::HostPath {
15310            declared: "~/lent".to_string(),
15311            path: PathBuf::from("/srv/lent"),
15312            access: crate::host_paths::HostPathAccess::Read,
15313            credential_locations: Vec::new(),
15314        }];
15315        enforcement
15316    }
15317
15318    /// A worker gets `Execute` on its own image's ELF closure and nothing else, no workdir
15319    /// `Execute`, none of the shell's grants or binds, no records directory, and its granted
15320    /// folders as read-write host paths.
15321    #[test]
15322    fn a_worker_enforcement_is_its_grant_closure_without_execute() {
15323        let image = std::env::current_exe().unwrap().canonicalize().unwrap();
15324        let state = PathBuf::from("/srv/state/notes");
15325        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
15326            let session = session(tier);
15327            let tools = session.for_guest_worker(
15328                &WorkerGrantClosure {
15329                    read_write: vec![state.clone()],
15330                    runtime_dir: true,
15331                    host_paths: true,
15332                },
15333                &image,
15334            );
15335            assert_eq!(tools.tier, tier);
15336            assert!(!tools.workdir_exec);
15337            assert!(tools.sealed_bind_dirs.is_empty());
15338            assert!(tools
15339                .landlock_grants
15340                .iter()
15341                .any(|grant| grant.path == image));
15342            for grant in &tools.landlock_grants {
15343                assert!(
15344                    resolve_landlock_grants(std::slice::from_ref(&image)).contains(&grant.path),
15345                    "{tier:?}: only the worker image's closure is granted: {grant:?}"
15346                );
15347            }
15348            assert!(!tools
15349                .landlock_grants
15350                .iter()
15351                .any(|grant| grant.path == Path::new("/usr/bin/bash")));
15352            let roots = tools
15353                .runtime_dir
15354                .as_ref()
15355                .expect("the tools worker reads it");
15356            assert!(roots.records.is_none(), "{tier:?}: no records directory");
15357            assert_eq!(roots.temp.is_some(), tier == EnforcementTier::KernelSealed);
15358            let host: Vec<_> = tools
15359                .host_paths
15360                .iter()
15361                .map(|path| (path.path.clone(), path.access))
15362                .collect();
15363            assert_eq!(
15364                host,
15365                vec![
15366                    (
15367                        PathBuf::from("/srv/lent"),
15368                        crate::host_paths::HostPathAccess::Read
15369                    ),
15370                    (state.clone(), crate::host_paths::HostPathAccess::ReadWrite),
15371                ]
15372            );
15373            assert_eq!(tools.resource_limits.memory_bytes, u64::MAX);
15374            assert_eq!(tools.resource_limits.max_processes, u64::MAX);
15375            assert_eq!(tools.resource_limits.cpu_seconds, None);
15376            assert!(tools.cgroup_scope.is_none() && tools.runtime_dir_owner.is_none());
15377            assert!(tools.fd_hygiene_fallback && !session.fd_hygiene_fallback);
15378
15379            let driver = session.for_guest_worker(&WorkerGrantClosure::default(), &image);
15380            assert!(driver.host_paths.is_empty(), "{tier:?}");
15381            assert_eq!(
15382                driver.runtime_dir.is_some(),
15383                tier == EnforcementTier::KernelSealed,
15384                "{tier:?}: only a composed root needs the runtime directory without a grant"
15385            );
15386            assert!(Arc::ptr_eq(&driver.session_netns, &session.session_netns));
15387        }
15388    }
15389}