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, the only writable paths outside the workdir), `/dev/zero` and `/dev/urandom`
46//!     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}
129
130/// Pure decision: given whether the host is Linux, the outcome of a Landlock
131/// ruleset-restriction probe, and what [`crate::namespace_probe`] created, decide the tier. No
132/// syscalls here — fully unit-testable on any OS.
133///
134/// `KernelSealed` requires the whole conjunction — Linux, a fully enforced Landlock ruleset, and a
135/// sealed-root rehearsal that really built the composed root inside the capsule's network
136/// namespace. Any missing element falls back to the tier that element still supports; nothing
137/// here ever reports a mechanism the host did not demonstrate, and nothing here reads AppArmor.
138pub(crate) fn tier_from_probe(
139    is_linux: bool,
140    landlock_fully_enforced: Option<bool>,
141    namespaces: &crate::namespace_probe::NamespaceReading,
142) -> EnforcementTier {
143    if !is_linux {
144        return EnforcementTier::EnvironmentOnly;
145    }
146    match landlock_fully_enforced {
147        Some(true) if namespaces.sealed_root_created() => EnforcementTier::KernelSealed,
148        Some(true) => EnforcementTier::KernelFull,
149        Some(false) | None => EnforcementTier::KernelSeccompOnly,
150    }
151}
152
153/// The tier a session applies on a host whose tier is `host_tier`: never stronger than the host
154/// can back and never stronger than the capsule asked for.
155///
156/// A capsule declaring less than `sealed` on a sealed-capable host runs on
157/// [`EnforcementTier::KernelFull`]: the composed root would otherwise delete host paths its
158/// `interpreter_runtime` grants legitimately point at — a narrowing of `scoped`'s behaviour, not
159/// added security. The declared floor is honoured, not merely met.
160///
161/// Called only by [`crate::containment::ContainmentDecision::decide`], which reads the session's
162/// achieved class off the tier this returns, so the class a report names and the tier the
163/// session's commands run on are one decision.
164pub(crate) fn applied_tier(
165    host_tier: EnforcementTier,
166    declared: murmur_artifact::ContainmentClass,
167) -> EnforcementTier {
168    match host_tier {
169        EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
170            EnforcementTier::KernelFull
171        }
172        other => other,
173    }
174}
175
176/// The host facts the tier decision is made from, taken together as one reading.
177///
178/// A staged session takes exactly one of these and every consumer of that session reads it — the
179/// containment-floor refusal, the `ScopeReport` written to the trace, the `/tmp` mapping, the
180/// `E-CAP-005` check at staging and at launch, and the tier [`ShellEnforcement::resolve`]
181/// installs. Two readings of a host that changed between them would let the trace describe a
182/// session that never ran under what it claims.
183#[derive(Debug, Clone)]
184pub struct HostProbe {
185    landlock_fully_enforced: Option<bool>,
186    /// The kernel's Landlock ABI, from `linux_enforce::kernel_landlock_abi`. `None` off Linux and
187    /// on a Linux kernel without Landlock.
188    landlock_kernel_abi: Option<u8>,
189    namespaces: crate::namespace_probe::NamespaceReading,
190}
191
192/// Probes taken since this process started. See [`HostProbe::probes_taken`].
193static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
194
195impl HostProbe {
196    /// Reads the host as it is right now: on Linux a real Landlock ruleset restriction attempt
197    /// (`RulesetStatus::FullyEnforced` → `Some(true)`, `PartiallyEnforced`/`NotEnforced` or any
198    /// construction error → `Some(false)`, no Landlock ABI → `None`) plus
199    /// [`crate::namespace_probe`]'s three attempts: the session's network namespace, a command's
200    /// private mount namespace inside it, and the composed root's nested namespace and pivot. Off
201    /// Linux it probes nothing and claims nothing.
202    ///
203    /// Taken once per staged session, not once per process. That is affordable because both
204    /// halves do their work in a forked child and leave the calling process exactly as they found
205    /// it — `linux_enforce::probe_landlock_full_access` forks so the runtime is never placed
206    /// into a Landlock domain by a capability check, and the namespace probe forks because
207    /// `unshare(CLONE_NEWUSER)` is irreversible for the task that calls it. Neither is
208    /// idempotency-sensitive, so a reading costs no more than the syscalls it makes: four
209    /// `fork`/`waitpid` pairs and three small `/proc` reads.
210    ///
211    /// The window this leaves open is one session wide. Between `runtime::stage_session` and the
212    /// last subprocess that session spawns the host is not re-read; a change inside that window is
213    /// caught by the mechanism installation itself failing closed, not by a second probe. A
214    /// process that stages repeatedly — `mur eval`, which stages once per dataset case — judges
215    /// each session against the host as it was when that session was staged.
216    pub fn probe() -> HostProbe {
217        PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
218        #[cfg(target_os = "linux")]
219        let probe = HostProbe {
220            landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
221            landlock_kernel_abi: linux_enforce::kernel_landlock_abi(),
222            namespaces: crate::namespace_probe::probe_namespaces(),
223        };
224        #[cfg(not(target_os = "linux"))]
225        let probe = HostProbe {
226            landlock_fully_enforced: None,
227            landlock_kernel_abi: None,
228            namespaces: crate::namespace_probe::probe_namespaces(),
229        };
230        probe
231    }
232
233    /// A probe value assembled from known halves, without touching the host. The seam the
234    /// fail-closed tests use to assert what an all-negative reading resolves to on any OS.
235    #[cfg(test)]
236    pub(crate) fn from_parts(
237        landlock_fully_enforced: Option<bool>,
238        namespaces: crate::namespace_probe::NamespaceReading,
239    ) -> HostProbe {
240        HostProbe {
241            landlock_fully_enforced,
242            landlock_kernel_abi: None,
243            namespaces,
244        }
245    }
246
247    /// This probe with its kernel Landlock ABI reading replaced. The seam the ABI tests use to
248    /// resolve a session as a kernel reporting `abi` would, on a host that reports another.
249    #[cfg(test)]
250    pub(crate) fn with_landlock_kernel_abi(self, abi: Option<u8>) -> HostProbe {
251        HostProbe {
252            landlock_kernel_abi: abi,
253            ..self
254        }
255    }
256
257    /// The tier this reading supports. `pub(crate)` because [`EnforcementTier`] is.
258    pub(crate) fn tier(&self) -> EnforcementTier {
259        tier_from_probe(
260            cfg!(target_os = "linux"),
261            self.landlock_fully_enforced,
262            &self.namespaces,
263        )
264    }
265
266    /// What the namespace probe attempted, and the readings it took with the attempts.
267    pub fn namespaces(&self) -> &crate::namespace_probe::NamespaceReading {
268        &self.namespaces
269    }
270
271    /// The `E-CAP-005` failure this reading's network attempt is, or `None` when it created the
272    /// namespace (and always off Linux).
273    pub fn egress_namespace_failure(
274        &self,
275    ) -> Option<crate::network_namespace::EgressNamespaceFailure> {
276        self.namespaces.egress_namespace_failure()
277    }
278
279    /// Whether this host can give a command a private mount namespace inside the session's user
280    /// namespace: the probe's mount attempt created one.
281    pub(crate) fn mount_namespace_usable(&self) -> bool {
282        cfg!(target_os = "linux") && self.namespaces.mount_namespace_created()
283    }
284
285    /// Why this reading cannot back `sealed`, or `None` when nothing keeps it below. `None`
286    /// exactly when [`Self::tier`] is `KernelSealed`.
287    pub fn sealed_shortfall(&self) -> Option<crate::sealed::SealedShortfall> {
288        crate::sealed::sealed_shortfall(
289            cfg!(target_os = "linux"),
290            self.landlock_fully_enforced == Some(true),
291            &self.namespaces,
292        )
293    }
294
295    /// Where this host's permission to create an unprivileged user namespace comes from, or
296    /// `None` off Linux, where the question has no answer rather than a negative one.
297    pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
298        if cfg!(target_os = "linux") {
299            Some(self.namespaces.userns_grant)
300        } else {
301            None
302        }
303    }
304
305    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock (off Linux
306    /// always). The ABI the session's ruleset is built at is [`ruleset_landlock_abi`] of this.
307    pub fn landlock_kernel_abi(&self) -> Option<u8> {
308        self.landlock_kernel_abi
309    }
310
311    /// How many probes this process has taken, counting from zero at startup and never
312    /// decreasing.
313    ///
314    /// The observability hook the freshness tests assert against — that staging twice advances it
315    /// by two, and that one `stage_session` advances it by one — and nothing else reads it. No
316    /// runtime decision depends on the count.
317    pub fn probes_taken() -> u64 {
318        PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
319    }
320}
321
322/// The tier this host can back, from a reading taken here. The seam the kernel tests resolve the
323/// host they run on through; a session reads its own [`HostProbe`].
324#[cfg(all(test, target_os = "linux"))]
325pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
326    HostProbe::probe().tier()
327}
328
329/// Why this host cannot back `sealed`, or `None` when nothing keeps it below, from a reading
330/// taken here.
331///
332/// Takes its own probe, as its neighbours do. What a session needs — that the refusal an operator
333/// sees and the tier the runtime installs cannot disagree about the host — is upheld by
334/// `runtime::stage_session` passing one [`HostProbe`] to both, not by this function.
335pub fn detect_sealed_shortfall() -> Option<crate::sealed::SealedShortfall> {
336    HostProbe::probe().sealed_shortfall()
337}
338
339/// Where this host's unprivileged-user-namespace permission comes from, or `None` off Linux, where
340/// the question has no answer rather than a negative one.
341///
342/// Takes its own probe, as its neighbours do; a session reads the provenance off its own
343/// [`HostProbe`] instead, so what it is shown and the tier it installs describe one host at one
344/// moment.
345pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
346    HostProbe::probe().userns_grant()
347}
348
349/// The kernel's Landlock ABI version, or `None` when there is no Landlock (off Linux always), from
350/// a reading taken here.
351///
352/// Takes its own probe, as its neighbours do; a session reads it off its own [`HostProbe`].
353pub fn detect_landlock_abi() -> Option<u8> {
354    HostProbe::probe().landlock_kernel_abi()
355}
356
357/// The highest Landlock ABI a capsule's ruleset is built at.
358///
359/// v2 adds `Refer` (moving and hard-linking between folders) and v3 adds `Truncate`, both granted
360/// on the workdir — see `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC`. v4 is not taken: it
361/// handles TCP `bind`/`connect`, and declaring those rights would deny every TCP connection the
362/// rules do not re-grant, which is the network namespace and egress proxy's boundary to draw. v5
363/// (`IoctlDev`) and v6 (abstract unix sockets and signals) each move a different boundary again.
364pub(crate) const LANDLOCK_TARGET_ABI: u8 = 3;
365
366/// The Landlock ABI this session's ruleset is built at: the kernel's, capped at
367/// [`LANDLOCK_TARGET_ABI`], on the two tiers that install a Landlock domain, and `None` on every
368/// other tier or when the kernel reports no Landlock.
369///
370/// Pure, so the ABI the trace records and the ABI the child builds at are the same function of
371/// the same [`HostProbe`] reading.
372pub(crate) fn ruleset_landlock_abi(kernel_abi: Option<u8>, tier: EnforcementTier) -> Option<u8> {
373    match tier {
374        EnforcementTier::KernelFull | EnforcementTier::KernelSealed => kernel_abi
375            .filter(|abi| *abi >= 1)
376            .map(|abi| abi.min(LANDLOCK_TARGET_ABI)),
377        EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => None,
378    }
379}
380
381/// The lines `mur doctor` prints under its `Landlock` heading for a kernel reporting
382/// `kernel_abi`: what the kernel offers, what the runtime builds at, and what a capsule loses on
383/// this host below [`LANDLOCK_TARGET_ABI`].
384///
385/// Pure and cross-platform, so a test can check the exact words for a kernel this host is not.
386pub fn landlock_doctor_lines(kernel_abi: Option<u8>) -> Vec<String> {
387    if !cfg!(target_os = "linux") {
388        return vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()];
389    }
390    let Some(kernel) = kernel_abi.filter(|abi| *abi >= 1) else {
391        return vec![
392            "kernel ABI: none (no Landlock on this host)".to_string(),
393            "ruleset ABI: n/a (no Landlock domain is installed)".to_string(),
394        ];
395    };
396    let built = kernel.min(LANDLOCK_TARGET_ABI);
397    let mut lines = vec![
398        format!("kernel ABI: {kernel}"),
399        format!("ruleset ABI: {built} (the kernel's, capped at {LANDLOCK_TARGET_ABI})"),
400    ];
401    if built < 2 {
402        lines.push(
403            "moving or hard-linking a file between folders inside the work folder fails with \
404             EXDEV (Invalid cross-device link) on this host; Linux 5.19 (Landlock ABI v2) \
405             permits it. Tools that fall back to copy-and-delete (mv, pip, git) still work; uv \
406             and other tools that rename atomically between folders do not."
407                .to_string(),
408        );
409    }
410    if built < 3 {
411        lines.push(
412            "truncate(2) on a file outside the work folder is not mediated below Landlock ABI v3 \
413             (Linux 6.2)."
414                .to_string(),
415        );
416    }
417    lines
418}
419
420/// Resolves every host in `network_allow` (via `crate::network_policy::parse_network_allow_rules`,
421/// the same parser already used for the WASI-HTTP-facing allowlist) to concrete IP addresses
422/// via DNS, once. Returns the deduplicated union of all resolved addresses across all allowed
423/// hosts.
424///
425/// Hosts that fail to resolve are silently skipped, matching `resolve_exec_allowlist`'s precedent
426/// below: the resolved set feeds *only* the kernel-level (Landlock/seccomp) allow set for
427/// `shell.allow` subprocesses, so a host contributing zero IPs merely *shrinks* that set (deny
428/// direction, fail-closed). A DNS miss must not be fatal — resolution happens unconditionally at
429/// staging time, before any WASM loads and regardless of whether the capsule declares
430/// `shell.allow` at all, so hard-failing here turned any transient resolver outage or
431/// non-resolving allowlist host into an E-IO-003 that blocked the whole run.
432///
433/// This does not widen what the capsule may reach: the WASM component's own outbound WASI-HTTP
434/// calls are gated separately by `network_policy::NetworkAllowRule::matches`, a host-pattern
435/// match that never consults DNS. Malformed host *syntax* is still a hard error — it is caught by
436/// `parse_network_allow_rules` above, before this loop.
437pub(crate) fn resolve_network_allowlist_ips(
438    network_allow: &[String],
439) -> Result<Vec<IpAddr>, String> {
440    let rules = crate::network_policy::parse_network_allow_rules(network_allow)
441        .map_err(|error| error.to_string())?;
442
443    let mut ips = std::collections::BTreeSet::new();
444    for rule in &rules {
445        let host = rule.host.as_str();
446        match crate::dns_resolver::resolve(host) {
447            crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
448            crate::dns_resolver::Resolution::DoesNotExist => {}
449            // Skipped like any other host that contributed no address, but said out loud: a
450            // resolver that did not answer is a transient condition an operator can act on,
451            // where a name that does not exist is a manifest to correct.
452            crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
453                "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
454                 resolved at launch: {reason} (a subprocess reaching it by literal address is \
455                 denied for this run)"
456            ),
457        }
458    }
459
460    Ok(ips.into_iter().collect())
461}
462
463/// Resolves each `capabilities.shell.allow` entry to the canonical filesystem path(s) of the
464/// binary it names, once, at launch time. Entries containing a path separator are
465/// canonicalized directly; bare names (the common case, per the manifest schema's
466/// "bare binary name" rule) are looked up in the host `PATH` the way `execvp` would, then
467/// canonicalized (resolving symlinks, `..`, etc.).
468///
469/// Entries that resolve to nothing (binary not installed) are silently skipped — that only
470/// *shrinks* the kernel-level allow set (deny direction, fail-closed) and matches reality:
471/// an exec of a binary that does not exist at launch fails anyway, and a binary *created
472/// after launch* under an allowlisted name (the attack this exists to stop) must not become
473/// allowlisted by virtue of its name.
474///
475/// This canonical set — not the raw name strings — is what `resolve_landlock_grants` derives the
476/// narrow read+execute Landlock grants from, so `cp /usr/bin/nc ./bash && ./bash` is denied: the
477/// copy lives under the workdir, which carries no `Execute` right by default, and the grant that
478/// does carry one names the launch-time `bash`'s own inode, not a basename.
479pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
480    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
481        .map(|path| std::env::split_paths(&path).collect())
482        .unwrap_or_default();
483    resolve_exec_allowlist_in(shell_allow, &path_dirs)
484}
485
486/// Testable core of `resolve_exec_allowlist`, with the `PATH` directory list injected. Every
487/// entry is a bare binary name (the manifest refuses any other, `E-MAN-003`), and every
488/// executable `PATH` match is included (not just the first): all of them are binaries the
489/// operator's launch-time `PATH` exposes under that allowlisted name, and on merged-`/usr` distros
490/// `/bin/x` and `/usr/bin/x` canonicalize to the same entry anyway.
491pub(crate) fn resolve_exec_allowlist_in(
492    shell_allow: &[String],
493    path_dirs: &[PathBuf],
494) -> Vec<PathBuf> {
495    let mut resolved = std::collections::BTreeSet::new();
496    for entry in shell_allow {
497        if entry.is_empty() {
498            continue;
499        }
500        for dir in path_dirs {
501            let candidate = dir.join(entry);
502            if is_executable_file(&candidate) {
503                if let Ok(canonical) = std::fs::canonicalize(&candidate) {
504                    resolved.insert(canonical);
505                }
506            }
507        }
508    }
509    resolved.into_iter().collect()
510}
511
512/// Resolves the program name a shell tool invoked to the canonical filesystem path of the
513/// binary that `execvp` would actually run, for reporting purposes: `shell-event.binary`
514/// on the hook side and the `binary` key of `trace.jsonl`'s `shell` record.
515///
516/// Unlike [`resolve_exec_allowlist`] — which collects *every* `PATH` match because each one needs
517/// its own Landlock grant — this returns the **first** match, which is the one `execvp` picks and
518/// therefore the one that ran. Resolution rules are otherwise identical (same `PATH` source, same
519/// [`is_executable_file`] test, same `canonicalize`), so a reported path is always a member of the
520/// set the Landlock grants were derived from.
521///
522/// A name that resolves to nothing falls back to the bare invoked name, unchanged — never
523/// an error, never a panic, matching `resolve_exec_allowlist`'s "entries that resolve to
524/// nothing are silently skipped" precedent. Observability must not be able to fail a shell
525/// call that the OS is perfectly willing to run.
526pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
527    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
528        .map(|path| std::env::split_paths(&path).collect())
529        .unwrap_or_default();
530    resolve_invoked_binary_path_in(binary, &path_dirs)
531}
532
533/// Testable core of [`resolve_invoked_binary_path`], with the `PATH` directory list
534/// injected. `pub(crate)` because [`crate::reachability`] needs the same first-match resolution
535/// against an *injected* directory list — its own unit tests point `PATH` at a temp directory of
536/// synthetic script fixtures rather than at whatever the machine running the suite has installed.
537pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
538    let fallback = || binary.to_string();
539    if binary.is_empty() {
540        return fallback();
541    }
542    if binary.contains('/') {
543        return match std::fs::canonicalize(binary) {
544            Ok(canonical) => canonical.to_string_lossy().into_owned(),
545            Err(_) => fallback(),
546        };
547    }
548    for dir in path_dirs {
549        let candidate = dir.join(binary);
550        if is_executable_file(&candidate) {
551            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
552                return canonical.to_string_lossy().into_owned();
553            }
554        }
555    }
556    fallback()
557}
558
559/// The first executable named `binary` on this process's `PATH`, or `None` when there is none.
560///
561/// First match wins, which is the one `execvp` would run — the same rule
562/// [`resolve_invoked_binary_path_in`] reports under, so a caller naming a binary and a caller
563/// reporting one cannot disagree about which file `PATH` means. The path is returned as found,
564/// not canonicalized: callers here name a binary to a person rather than derive a grant from it.
565pub fn find_on_path(binary: &str) -> Option<PathBuf> {
566    std::env::split_paths(&std::env::var_os("PATH")?)
567        .map(|dir| dir.join(binary))
568        .find(|candidate| is_executable_file(candidate))
569}
570
571pub(crate) fn is_executable_file(path: &Path) -> bool {
572    use std::os::unix::fs::PermissionsExt;
573    std::fs::metadata(path)
574        .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
575        .unwrap_or(false)
576}
577
578// ---- derived Landlock read+execute grant set ----
579//
580// On `KernelFull`, Landlock scopes the shell subprocess tree to the workdir. The workdir rule
581// grants `linux_enforce::workdir_access_rights` (every right of the ABI the ruleset is built at
582// except device-node and unix-socket creation, and `Execute` unless `workdir_exec`), but — once
583// `restrict_self()` lands — `Execute`/`ReadFile` are then denied on every path *outside* the
584// workdir, including the `shell.allow` binaries themselves, their ELF interpreter (dynamic loader),
585// and every shared library they pull in. That silently defeats the seccomp exec-allowlist: each
586// allowlisted `execve` fails with EACCES before it runs.
587//
588// The functions below derive the exact extra paths that must get a narrow read+execute grant
589// outside the workdir: the canonical `shell.allow` binaries (already resolved by
590// `resolve_exec_allowlist`), each one's `PT_INTERP`, and the transitive closure of the shared
591// libraries their ELF `DT_NEEDED` entries name (resolved via `DT_RPATH`/`DT_RUNPATH` first, then a
592// fixed set of standard library directories). Nothing broader — no directory is granted wholesale.
593//
594// All of this is *parsing and filesystem resolution*, so it runs in the parent at launch time
595// (inside `ShellEnforcement::resolve`) and the resulting `Vec<PathBuf>` is threaded into the
596// forked child's `pre_exec`, which only opens each path and adds a Landlock rule (no parsing in
597// the async-signal-safe window). Every resolution step is shrink-not-fail, matching
598// `resolve_exec_allowlist`: an entry that does not exist, does not parse as ELF, or names a
599// soname that does not resolve simply contributes nothing further — it never errors out.
600
601/// Standard Linux shared-library search directories used as the fallback resolution set for a
602/// `DT_NEEDED` soname when the binary declares no `DT_RPATH`/`DT_RUNPATH` (or it doesn't match).
603/// Covers both supported platform targets (`linux/x86_64` and `linux/aarch64`), including the
604/// Debian/Ubuntu multiarch-triplet subdirectories. Extend here for a platform this list does not
605/// cover — it is the single source of truth.
606pub(crate) const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
607    "/lib",
608    "/lib64",
609    "/usr/lib",
610    "/usr/lib64",
611    "/lib/x86_64-linux-gnu",
612    "/usr/lib/x86_64-linux-gnu",
613    "/lib/aarch64-linux-gnu",
614    "/usr/lib/aarch64-linux-gnu",
615];
616
617/// The subset of an ELF binary's dynamic-linking metadata that decides which extra files the
618/// dynamic loader will touch at load time: the interpreter path (`PT_INTERP`), the direct
619/// shared-library dependencies (`DT_NEEDED` sonames), and the runtime library search paths
620/// (`DT_RPATH`/`DT_RUNPATH`, already split on `:`, `$ORIGIN` left unexpanded here).
621#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
622#[serde(deny_unknown_fields)]
623pub(crate) struct ElfDependencies {
624    pub(crate) interp: Option<String>,
625    pub(crate) needed: Vec<String>,
626    pub(crate) runpaths: Vec<String>,
627}
628
629/// An ELF image the parsers below read by position: its length, and the bytes at an offset. Each
630/// parser asks only for the headers and tables it decodes, so an image read from a file costs a
631/// few `pread`s, never the whole file.
632pub(crate) trait ElfSource {
633    /// The image's length in bytes.
634    fn size(&self) -> u64;
635
636    /// The `len` bytes at `offset`, or `None` when they do not lie wholly inside the image or
637    /// cannot be read.
638    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>>;
639}
640
641/// An image already in memory, such as a native tool the runtime has just written.
642impl ElfSource for [u8] {
643    fn size(&self) -> u64 {
644        self.len() as u64
645    }
646
647    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
648        let start = usize::try_from(offset).ok()?;
649        let end = start.checked_add(usize::try_from(len).ok()?)?;
650        self.get(start..end).map(std::borrow::Cow::Borrowed)
651    }
652}
653
654/// An image read from an open file with `pread`, its length the file's size when it was opened.
655pub(crate) struct ElfFile {
656    file: std::fs::File,
657    metadata: std::fs::Metadata,
658}
659
660impl ElfFile {
661    /// Opens the host file at `path` for reading.
662    pub(crate) fn open_host(path: &Path) -> std::io::Result<Self> {
663        let file = std::fs::File::open(path)?;
664        let metadata = file.metadata()?;
665        Ok(Self { file, metadata })
666    }
667
668    /// The open file's metadata, read from the handle when it was opened.
669    #[cfg(target_os = "linux")]
670    pub(crate) fn metadata(&self) -> &std::fs::Metadata {
671        &self.metadata
672    }
673}
674
675impl ElfSource for ElfFile {
676    fn size(&self) -> u64 {
677        self.metadata.len()
678    }
679
680    fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
681        use std::os::unix::fs::FileExt;
682
683        if offset.checked_add(len)? > self.size() {
684            return None;
685        }
686        let mut bytes = vec![0; usize::try_from(len).ok()?];
687        self.file.read_exact_at(&mut bytes, offset).ok()?;
688        Some(std::borrow::Cow::Owned(bytes))
689    }
690}
691
692fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
693    let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
694    Some(if le {
695        u16::from_le_bytes(raw)
696    } else {
697        u16::from_be_bytes(raw)
698    })
699}
700
701fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
702    let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
703    Some(if le {
704        u32::from_le_bytes(raw)
705    } else {
706        u32::from_be_bytes(raw)
707    })
708}
709
710fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
711    let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
712    Some(if le {
713        u64::from_le_bytes(raw)
714    } else {
715        u64::from_be_bytes(raw)
716    })
717}
718
719/// Whether the ELF identification at the start of `header` is ELF64 little-endian (`true`) or
720/// big-endian (`false`); `None` for anything else.
721fn elf64_little_endian(header: &[u8]) -> Option<bool> {
722    // EI_CLASS: 2 == ELFCLASS64. EI_DATA: 1 == little-endian, 2 == big-endian.
723    if header.get(0..4)? != b"\x7fELF" || *header.get(4)? != 2 {
724        return None;
725    }
726    match header.get(5)? {
727        1 => Some(true),
728        2 => Some(false),
729        _ => None,
730    }
731}
732
733/// The `len` bytes at `offset`, or as many of them as `image` holds; empty when `offset` is at or
734/// past its end.
735fn read_clamped<S: ElfSource + ?Sized>(
736    image: &S,
737    offset: u64,
738    len: u64,
739) -> Option<std::borrow::Cow<'_, [u8]>> {
740    let available = image.size().saturating_sub(offset).min(len);
741    if available == 0 {
742        return Some(std::borrow::Cow::Borrowed(&[]));
743    }
744    image.read_at(offset, available)
745}
746
747/// Reads a NUL-terminated string starting at the beginning of `region` (used for both the
748/// `PT_INTERP` segment and dynamic-string-table entries).
749fn cstr_at_start(region: &[u8]) -> String {
750    let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
751    String::from_utf8_lossy(&region[..nul]).into_owned()
752}
753
754/// Maps a virtual address into a file offset using the ELF's `PT_LOAD` segments — required
755/// because `DT_STRTAB` records the string table's virtual address, not its file offset.
756fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
757    for &(seg_vaddr, seg_off, seg_filesz) in loads {
758        if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
759            return Some(seg_off + (vaddr - seg_vaddr));
760        }
761    }
762    None
763}
764
765/// Pure ELF64 parser: extracts the dynamic-linking metadata that determines which extra files the
766/// loader touches. Returns `None` for anything that is not a well-formed ELF64 image (bad magic,
767/// 32-bit, truncated headers) — a non-ELF or unparseable binary simply contributes nothing to the
768/// grant set (shrink-not-fail). Deliberately hand-rolled (no crate dependency) so it is fully
769/// unit-testable off-Linux with synthetic in-memory fixtures.
770///
771/// Reads only the 64-byte header, the program header table, the `PT_INTERP` bytes, the
772/// `PT_DYNAMIC` array and the dynamic string table. Every read is bounded by the image's length,
773/// so the answer is the same whether `image` is in memory or a file read by position.
774///
775/// Only ELF64 is parsed: both supported Linux targets (`x86_64`, `aarch64`) are 64-bit, so a
776/// 32-bit image is not a binary this runtime would exec and is treated as unparseable.
777pub(crate) fn parse_elf_dependencies<S: ElfSource + ?Sized>(image: &S) -> Option<ElfDependencies> {
778    // e_ident (16) + fixed ELF64 header fields extend to offset 64.
779    let header = image.read_at(0, 64)?;
780    let le = elf64_little_endian(&header)?;
781
782    let e_phoff = read_u64(&header, 32, le)?;
783    let e_phentsize = u64::from(read_u16(&header, 54, le)?);
784    let e_phnum = u64::from(read_u16(&header, 56, le)?);
785    // Each Elf64_Phdr is 56 bytes; a smaller entsize is not this layout.
786    if e_phentsize < 56 {
787        return None;
788    }
789
790    let mut interp: Option<String> = None;
791    let mut dynamic_region: Option<(u64, u64)> = None; // (file offset, size)
792    let mut loads: Vec<(u64, u64, u64)> = Vec::new(); // (p_vaddr, p_offset, p_filesz)
793
794    // Every field read below lies in the first 40 bytes of an entry, so the table ends 40 bytes
795    // into its last entry.
796    let table = match e_phnum.checked_sub(1) {
797        None => std::borrow::Cow::Borrowed(&[][..]),
798        Some(last) => image.read_at(e_phoff, last.checked_mul(e_phentsize)?.checked_add(40)?)?,
799    };
800    for i in 0..e_phnum {
801        let base = usize::try_from(i * e_phentsize).ok()?;
802        let p_type = read_u32(&table, base, le)?;
803        let p_offset = read_u64(&table, base + 8, le)?;
804        let p_vaddr = read_u64(&table, base + 16, le)?;
805        let p_filesz = read_u64(&table, base + 32, le)?;
806        match p_type {
807            1 => loads.push((p_vaddr, p_offset, p_filesz)), // PT_LOAD
808            2 => dynamic_region = Some((p_offset, p_filesz)), // PT_DYNAMIC
809            3 => {
810                // PT_INTERP: a NUL-terminated interpreter path in the file image.
811                interp = Some(cstr_at_start(&image.read_at(p_offset, p_filesz)?));
812            }
813            _ => {}
814        }
815    }
816
817    // A static binary (no PT_DYNAMIC) contributes only itself — no interp, no needed libs.
818    let Some((dyn_off, dyn_size)) = dynamic_region else {
819        return Some(ElfDependencies {
820            interp,
821            needed: Vec::new(),
822            runpaths: Vec::new(),
823        });
824    };
825
826    let mut needed_offsets: Vec<u64> = Vec::new();
827    let mut runpath_offsets: Vec<u64> = Vec::new();
828    let mut strtab_vaddr: Option<u64> = None;
829    let mut strsz: Option<u64> = None;
830
831    dyn_off.checked_add(dyn_size)?;
832    // Entries after `DT_NULL` are never read, so a segment that runs past the end of the file is
833    // read as far as the file goes, and an entry before `DT_NULL` beyond that fails the parse.
834    let dynamic = read_clamped(image, dyn_off, dyn_size)?;
835    let mut off = 0u64;
836    while off + 16 <= dyn_size {
837        let entry = dynamic.get(usize::try_from(off).ok()?..usize::try_from(off + 16).ok()?)?;
838        let d_tag = read_u64(entry, 0, le)?;
839        let d_val = read_u64(entry, 8, le)?;
840        match d_tag {
841            0 => break,                             // DT_NULL — end of dynamic array
842            1 => needed_offsets.push(d_val),        // DT_NEEDED
843            5 => strtab_vaddr = Some(d_val),        // DT_STRTAB (virtual address)
844            10 => strsz = Some(d_val),              // DT_STRSZ
845            15 | 29 => runpath_offsets.push(d_val), // DT_RPATH | DT_RUNPATH
846            _ => {}
847        }
848        off += 16;
849    }
850
851    let mut needed = Vec::new();
852    let mut runpaths = Vec::new();
853    if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
854        if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
855            // Prefer the declared string-table bounds, but tolerate an over-long DT_STRSZ by
856            // falling back to the rest of the file rather than dropping every soname.
857            let strtab = (strtab_off <= image.size())
858                .then(|| read_clamped(image, strtab_off, strsz))
859                .flatten();
860            if let Some(strtab) = strtab {
861                for offset in needed_offsets {
862                    if let Some(region) = strtab.get(offset as usize..) {
863                        needed.push(cstr_at_start(region));
864                    }
865                }
866                for offset in runpath_offsets {
867                    if let Some(region) = strtab.get(offset as usize..) {
868                        for part in cstr_at_start(region).split(':') {
869                            if !part.is_empty() {
870                                runpaths.push(part.to_string());
871                            }
872                        }
873                    }
874                }
875            }
876        }
877    }
878
879    Some(ElfDependencies {
880        interp,
881        needed,
882        runpaths,
883    })
884}
885
886/// Whether `image` is an ELF64 shared object (`e_type` `ET_DYN`), in either byte order. Reads only
887/// the first 18 bytes, so a caller sorting a directory reads no more of each file than that. A
888/// position-independent executable is `ET_DYN` too and answers `true`.
889#[cfg(target_os = "linux")]
890pub(crate) fn elf_is_shared_object<S: ElfSource + ?Sized>(image: &S) -> bool {
891    let Some(head) = image.read_at(0, 18) else {
892        return false;
893    };
894    elf64_little_endian(&head).is_some_and(|le| read_u16(&head, 16, le) == Some(3))
895}
896
897/// The name of every entry in an ELF64 image's dynamic symbol table (`.dynsym`), defined and
898/// undefined alike, in table order. `None` for anything that is not a well-formed ELF64 image or
899/// has no `.dynsym` section.
900///
901/// Reads the header, the section headers up to the `SHT_DYNSYM` one, that section and the string
902/// table its `sh_link` names, and nothing else of the image. A symbol the binary imports from a
903/// shared library is listed here as well as one it defines, so an interpreter built against
904/// `libpython` or `libperl` names its entry symbol the same way a statically linked one does. A
905/// binary stripped of its section headers names nothing.
906#[cfg(target_os = "linux")]
907pub(crate) fn elf_dynamic_symbol_names<S: ElfSource + ?Sized>(image: &S) -> Option<Vec<String>> {
908    let header = image.read_at(0, 64)?;
909    let le = elf64_little_endian(&header)?;
910    let e_shoff = read_u64(&header, 40, le)?;
911    let e_shentsize = u64::from(read_u16(&header, 58, le)?);
912    let e_shnum = u64::from(read_u16(&header, 60, le)?);
913    // Each Elf64_Shdr is 64 bytes; a smaller entsize is not this layout.
914    if e_shentsize < 64 {
915        return None;
916    }
917    // (sh_type, sh_offset, sh_size, sh_link, sh_entsize) of section `index`.
918    let section = |index: u64| -> Option<(u32, u64, u64, u64, u64)> {
919        let base = e_shoff.checked_add(index.checked_mul(e_shentsize)?)?;
920        let entry = image.read_at(base, 64)?;
921        Some((
922            read_u32(&entry, 4, le)?,
923            read_u64(&entry, 24, le)?,
924            read_u64(&entry, 32, le)?,
925            u64::from(read_u32(&entry, 40, le)?),
926            read_u64(&entry, 56, le)?,
927        ))
928    };
929    // SHT_DYNSYM == 11.
930    let (_, sym_off, sym_size, strtab_index, sym_entsize) = (0..e_shnum)
931        .filter_map(section)
932        .find(|(sh_type, ..)| *sh_type == 11)?;
933    // Each Elf64_Sym is 24 bytes.
934    if sym_entsize < 24 {
935        return None;
936    }
937    let (_, str_off, str_size, _, _) = section(strtab_index)?;
938    let strtab = image.read_at(str_off, str_size)?;
939    let symbols = image.read_at(sym_off, sym_size)?;
940    let mut names = Vec::new();
941    for symbol in symbols.chunks_exact(usize::try_from(sym_entsize).ok()?) {
942        let st_name = read_u32(symbol, 0, le)? as usize;
943        if st_name == 0 {
944            continue;
945        }
946        if let Some(region) = strtab.get(st_name..) {
947            names.push(cstr_at_start(region));
948        }
949    }
950    Some(names)
951}
952
953/// Expands a leading/embedded `$ORIGIN` (or `${ORIGIN}`) in an rpath entry to the directory of the
954/// binary that declared it — the dynamic loader's own rule for `DT_RUNPATH`/`DT_RPATH`.
955fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
956    if rpath.contains("$ORIGIN") {
957        let origin = origin_dir.to_string_lossy();
958        PathBuf::from(
959            rpath
960                .replace("${ORIGIN}", &origin)
961                .replace("$ORIGIN", &origin),
962        )
963    } else {
964        PathBuf::from(rpath)
965    }
966}
967
968/// Resolves one `DT_NEEDED` soname to a canonical file path, mirroring the loader's search order:
969/// a soname containing a `/` is taken as a path (absolute, or relative to the binary's directory);
970/// otherwise the binary's own `DT_RUNPATH`/`DT_RPATH` entries (with `$ORIGIN` expanded) are tried
971/// first, then the standard `search_dirs`. Returns `None` (shrink-not-fail) if nothing matches.
972pub(crate) fn resolve_soname(
973    soname: &str,
974    origin_dir: &Path,
975    runpaths: &[String],
976    search_dirs: &[PathBuf],
977) -> Option<PathBuf> {
978    if soname.is_empty() {
979        return None;
980    }
981    if soname.contains('/') {
982        let candidate = if Path::new(soname).is_absolute() {
983            PathBuf::from(soname)
984        } else {
985            origin_dir.join(soname)
986        };
987        return std::fs::canonicalize(&candidate).ok();
988    }
989    for rpath in runpaths {
990        let candidate = expand_origin(rpath, origin_dir).join(soname);
991        if candidate.is_file() {
992            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
993                return Some(canonical);
994            }
995        }
996    }
997    for dir in search_dirs {
998        let candidate = dir.join(soname);
999        if candidate.is_file() {
1000            if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1001                return Some(canonical);
1002            }
1003        }
1004    }
1005    None
1006}
1007
1008/// Derives the full set of canonical file paths that need a narrow read+execute Landlock grant
1009/// *outside* the workdir so the `shell.allow` binaries can actually run: each allowlisted binary,
1010/// its ELF interpreter, and the transitive closure of its `DT_NEEDED` shared libraries. Uses the
1011/// standard [`DEFAULT_LIBRARY_SEARCH_DIRS`] as the soname fallback search set.
1012///
1013/// Every step is shrink-not-fail (a binary that can't be read/parsed, or a soname that doesn't
1014/// resolve, contributes nothing further); the returned set is deduplicated and canonical. Runs in
1015/// the parent at launch time — never in the forked child's `pre_exec`.
1016pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
1017    let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1018        .iter()
1019        .map(PathBuf::from)
1020        .collect();
1021    resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
1022}
1023
1024/// Testable core of [`resolve_landlock_grants`], with the library search directories injected so
1025/// unit tests can point it at synthetic fixtures instead of the real system directories.
1026fn resolve_landlock_grants_in(
1027    exec_allow_paths: &[PathBuf],
1028    search_dirs: &[PathBuf],
1029) -> Vec<PathBuf> {
1030    resolve_landlock_grants_outside(exec_allow_paths, search_dirs, &[])
1031}
1032
1033/// [`resolve_landlock_grants_in`], skipping every path under one of the canonical `excluded`
1034/// roots: such a path is neither granted nor read for further dependencies. For a closure that
1035/// must not be decided by files the capsule can write.
1036pub(crate) fn resolve_landlock_grants_outside(
1037    exec_allow_paths: &[PathBuf],
1038    search_dirs: &[PathBuf],
1039    excluded: &[PathBuf],
1040) -> Vec<PathBuf> {
1041    let Ok(grants) = walk_dependency_closure(
1042        exec_allow_paths,
1043        search_dirs,
1044        excluded,
1045        |path| -> Result<_, std::convert::Infallible> { Ok(read_elf_dependencies(path)) },
1046    );
1047    grants
1048}
1049
1050/// The dependencies of the ELF image at `path`, read through [`ElfFile`]. `None` when it cannot
1051/// be opened or does not parse.
1052fn read_elf_dependencies(path: &Path) -> Option<ElfDependencies> {
1053    parse_elf_dependencies(&ElfFile::open_host(path).ok()?)
1054}
1055
1056/// The walk behind [`resolve_landlock_grants_outside`], with each visited file's dependencies
1057/// taken from `dependencies`, which is asked once per visited path. An error from it ends the
1058/// walk with that error.
1059pub(crate) fn walk_dependency_closure<E>(
1060    exec_allow_paths: &[PathBuf],
1061    search_dirs: &[PathBuf],
1062    excluded: &[PathBuf],
1063    mut dependencies: impl FnMut(&Path) -> Result<Option<ElfDependencies>, E>,
1064) -> Result<Vec<PathBuf>, E> {
1065    let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1066    let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1067    let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
1068
1069    for path in exec_allow_paths {
1070        // The allowlist paths are already canonical/existing (from `resolve_exec_allowlist`), but
1071        // canonicalize defensively — this both makes dedup against interp/library paths exact and
1072        // drops any seed that no longer exists (shrink-not-fail: a vanished binary contributes
1073        // nothing, and could not be exec'd anyway).
1074        if let Ok(canonical) = std::fs::canonicalize(path) {
1075            queue.push_back(canonical);
1076        }
1077    }
1078
1079    while let Some(path) = queue.pop_front() {
1080        if excluded.iter().any(|root| path.starts_with(root)) {
1081            continue;
1082        }
1083        if !visited.insert(path.clone()) {
1084            continue;
1085        }
1086        // The binary/library itself is granted. If it lives inside the workdir, the workdir's own
1087        // full-access rule already covers it and this extra read+execute rule is simply redundant.
1088        grants.insert(path.clone());
1089
1090        let Some(deps) = dependencies(&path)? else {
1091            continue;
1092        };
1093        let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
1094
1095        if let Some(interp) = &deps.interp {
1096            if let Ok(canonical) = std::fs::canonicalize(interp) {
1097                queue.push_back(canonical);
1098            }
1099        }
1100        for soname in &deps.needed {
1101            if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
1102            {
1103                queue.push_back(resolved);
1104            }
1105        }
1106    }
1107
1108    Ok(grants.into_iter().collect())
1109}
1110
1111/// One resolved Landlock filesystem grant outside the workdir: a canonical path plus the two
1112/// independent bits that decide the rule's access set.
1113///
1114/// `list_dir` decides enumerability (`ReadDir`):
1115///
1116///   - `false` → no `getdents64` on the granted directory itself. Landlock's read rights apply to
1117///     the whole subtree beneath a granted directory, so a file *inside* it can still be opened by
1118///     its exact name — the listing is what is denied. This is what every derived
1119///     `DT_NEEDED`-closure grant gets — they are individual files, where `ReadDir` was always a
1120///     no-op anyway (Landlock's `ReadDir` only has meaning on a directory inode).
1121///   - `true` → adds enumerability, which a path-based interpreter's import machinery needs on
1122///     each `sys.path` entry (CPython's `FileFinder` `listdir`-caches each one). Set by an author
1123///     writing `list_dir: true` next to a specific `interpreter_runtime` directory, on each
1124///     directory a recognised interpreter reports on its own import path
1125///     ([`resolve_derived_interpreter_grants`]), and by the whole-tree resolvers
1126///     ([`resolve_staged_runtime_landlock_grants`], [`resolve_sealed_runtime_landlock_grants`]
1127///     and, per entry, the directory half of [`resolve_sealed_etc_landlock_grants`]) where a tree
1128///     is walked by definition — never inferred from anything else, and never applied to an
1129///     ancestor or sibling of a granted directory.
1130///
1131/// `ReadDir` is meaningful only on a directory, and the kernel enforces that: `landlock_add_rule`
1132/// returns `EINVAL` for a directory-only right on a non-directory. `open_landlock_fds` therefore
1133/// narrows `list_dir` to `false` for any grant whose fd does not `fstat` as a directory, so this
1134/// field is a request rather than a promise.
1135///
1136/// `executable` decides `Execute`, which is this runtime's exec allowlist: a path without an
1137/// `Execute` rule cannot be `execve`d, and a path with one can. This is the canonical statement of
1138/// that rule; the grant resolvers below cross-reference it rather than restating it.
1139///
1140///   - `true` → `Execute + ReadFile`, for a path the manifest asked to *run*: a `shell.allow`
1141///     binary, its `DT_NEEDED` closure, and the interpreter/staged trees an author named.
1142///   - `false` → `ReadFile` only (readable, and with `list_dir` enumerable, but not runnable).
1143///     For a grant the *tier* issues rather than the manifest: see
1144///     [`resolve_sealed_runtime_landlock_grants`], where granting `Execute` over `/usr`, `/bin`
1145///     and `/sbin` wholesale would turn `shell.allow` into a no-op on `sealed`, and
1146///     [`resolve_sealed_etc_landlock_grants`], where `/etc/alternatives` is a directory of
1147///     symlinks into exactly those trees.
1148///
1149/// Affects `execve` only: a shared object mapped `PROT_EXEC` by `dlopen(3)` needs `ReadFile`, not
1150/// `Execute`, so an interpreter still loads its C extensions out of an `executable: false` tree.
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152pub(crate) struct LandlockGrant {
1153    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1154    pub(crate) path: PathBuf,
1155    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1156    pub(crate) list_dir: bool,
1157    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1158    pub(crate) executable: bool,
1159    /// When set, the rule is added only if `path` still opens to this inode, and is otherwise
1160    /// dropped (shrink-not-fail). For a grant on a capsule-writable path: the capsule can replace
1161    /// the file, or a directory above it, with a symlink to any host binary, and an unpinned grant
1162    /// would follow it and put `Execute` on that binary. A pin tells one live file from another,
1163    /// not new content from old: an inode number freed by a delete can be reused by the next file.
1164    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1165    pub(crate) pinned: Option<FileIdentity>,
1166}
1167
1168/// A file's `(st_dev, st_ino)`: which inode a path named when the runtime recorded it.
1169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1170pub(crate) struct FileIdentity {
1171    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1172    dev: u64,
1173    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1174    ino: u64,
1175}
1176
1177impl FileIdentity {
1178    #[cfg(unix)]
1179    pub(crate) fn of(metadata: &std::fs::Metadata) -> FileIdentity {
1180        use std::os::unix::fs::MetadataExt;
1181        FileIdentity {
1182            dev: metadata.dev(),
1183            ino: metadata.ino(),
1184        }
1185    }
1186}
1187
1188impl LandlockGrant {
1189    /// Wraps each derived `DT_NEEDED`-closure file path as a non-listable grant. A regular file
1190    /// has no meaningful `ReadDir`, so `list_dir: false` costs nothing. `executable: true`: this
1191    /// closure is exactly the set of paths `shell.allow` asked to run — see [`LandlockGrant`].
1192    fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
1193        paths
1194            .into_iter()
1195            .map(|path| LandlockGrant {
1196                path,
1197                list_dir: false,
1198                executable: true,
1199                pinned: None,
1200            })
1201            .collect()
1202    }
1203}
1204
1205/// Turns each `capabilities.shell.interpreter_runtime` directory into a [`LandlockGrant`] carrying
1206/// exactly the `list_dir` its author wrote. Pure and syscall-free (the declared paths need not
1207/// exist here — `apply_landlock_scope` skips any that fail to open), so it is unit-testable on
1208/// every platform, including non-Linux targets.
1209///
1210/// There is deliberately no path here that could widen a grant to a whole install prefix: it emits
1211/// one grant per explicitly declared directory and copies the author's `list_dir` verbatim,
1212/// inferring enumerability from nothing. A recognised interpreter's own import path is granted
1213/// separately, by [`resolve_derived_interpreter_grants`]; this is the route for an interpreter the
1214/// runtime does not recognise.
1215pub(crate) fn resolve_interpreter_runtime_grants(
1216    grants: &[InterpreterRuntimeGrant],
1217) -> Vec<LandlockGrant> {
1218    grants
1219        .iter()
1220        .flat_map(|grant| {
1221            grant.dirs.iter().map(|dir| LandlockGrant {
1222                path: PathBuf::from(&dir.path),
1223                list_dir: dir.list_dir,
1224                // Executable: an `interpreter_runtime` directory is named by an author precisely so
1225                // the interpreter it belongs to can run out of it, helper binaries included.
1226                executable: true,
1227                pinned: None,
1228            })
1229        })
1230        .collect()
1231}
1232
1233/// One [`LandlockGrant`] per entry staging derived from a recognised interpreter (see
1234/// [`crate::interpreter_runtime`]): each directory on its import path readable and listable, and
1235/// each zip archive on it and each extension-module library readable only.
1236///
1237/// `executable: false` on every one (see [`LandlockGrant`]): the manifest asked to run the
1238/// interpreter, which its own ELF closure grants, not the files it imports. A C extension module
1239/// still loads, because `dlopen(3)` needs `ReadFile` alone. The derivation never names `/` or an
1240/// ancestor of the interpreter's directory, so no grant here widens to an install prefix.
1241pub(crate) fn resolve_derived_interpreter_grants(
1242    derived: &[crate::interpreter_runtime::DerivedInterpreterRuntime],
1243) -> Vec<LandlockGrant> {
1244    let read_only = |path: &Path, list_dir: bool| LandlockGrant {
1245        path: path.to_path_buf(),
1246        list_dir,
1247        executable: false,
1248        pinned: None,
1249    };
1250    derived
1251        .iter()
1252        .flat_map(|runtime| {
1253            runtime
1254                .entries()
1255                .map(|(path, is_dir)| read_only(path, is_dir))
1256                .chain(runtime.libraries.iter().map(|path| read_only(path, false)))
1257                .collect::<Vec<_>>()
1258        })
1259        .collect()
1260}
1261
1262/// One [`LandlockGrant`] per host trust-store path staging found (see [`crate::trust_store`]): a
1263/// directory readable and listable, a CA bundle file readable.
1264///
1265/// `executable: false` on every one (see [`LandlockGrant`]): a certificate is read, never run. No
1266/// write right, as on every [`LandlockGrant`]. The network gate (a non-empty `shell.allow` and a
1267/// non-empty `network.allow`) lives in [`crate::trust_store::derive_trust_store`], so a policy
1268/// that fails it reaches here with nothing to grant; the derivation never names `/` or an
1269/// ancestor of a trust-store path, so no grant here reaches `/etc/ssl/private`.
1270///
1271/// Issued on every tier and consulted only on `KernelFull` and `KernelSealed`. On `KernelSealed`
1272/// a path the composed root does not bind stays unreachable: this grant adds no bind.
1273pub(crate) fn resolve_trust_store_grants(
1274    paths: &[crate::trust_store::TrustStorePath],
1275) -> Vec<LandlockGrant> {
1276    paths
1277        .iter()
1278        .map(|entry| LandlockGrant {
1279            path: entry.path.clone(),
1280            list_dir: entry.directory,
1281            executable: false,
1282            pinned: None,
1283        })
1284        .collect()
1285}
1286
1287/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
1288/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
1289/// allowlisted binary, each declared `interpreter_runtime` directory, and each directory a
1290/// recognised interpreter reported on its import path with the parent directory of each library
1291/// its extension modules need.
1292///
1293/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
1294/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
1295/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
1296/// derivation `scoped` needs has nothing left to do here.
1297///
1298/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
1299/// makes the composed root narrower rather than refusing the launch, which is why
1300/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
1301/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
1302/// required bind. This function is additive to that, not replaced by it.
1303///
1304/// Pure and syscall-free, so it is unit-testable on any platform.
1305pub(crate) fn resolve_sealed_bind_dirs(
1306    exec_allow_paths: &[PathBuf],
1307    policy: &CapabilityPolicy,
1308) -> Vec<PathBuf> {
1309    let mut dirs: Vec<PathBuf> = Vec::new();
1310    let candidates = exec_allow_paths
1311        .iter()
1312        .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1313        .chain(
1314            policy
1315                .shell_interpreter_runtime
1316                .iter()
1317                .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1318        )
1319        .chain(
1320            policy
1321                .shell_derived_interpreter_runtime
1322                .iter()
1323                .flat_map(|runtime| {
1324                    runtime
1325                        .entries()
1326                        .filter(|(_, is_dir)| *is_dir)
1327                        .map(|(path, _)| path.to_path_buf())
1328                        .chain(
1329                            runtime
1330                                .libraries
1331                                .iter()
1332                                .filter_map(|library| library.parent().map(Path::to_path_buf)),
1333                        )
1334                        .collect::<Vec<_>>()
1335                }),
1336        );
1337
1338    for candidate in candidates {
1339        push_sealed_bind_dir(&mut dirs, candidate);
1340    }
1341    dirs
1342}
1343
1344/// Adds `candidate` to `dirs` as an optional composed-root bind unless it is relative, already
1345/// inside a fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, or already listed. The one filter
1346/// [`resolve_sealed_bind_dirs`] and [`ShellEnforcement::for_native_entrypoint`] share.
1347fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1348    if !candidate.is_absolute() {
1349        return;
1350    }
1351    if crate::sealed::SEALED_RUNTIME_PATHS
1352        .iter()
1353        .any(|root| candidate.starts_with(root))
1354    {
1355        return;
1356    }
1357    if dirs.contains(&candidate) {
1358        return;
1359    }
1360    dirs.push(candidate);
1361}
1362
1363/// The most bytes [`shebang_interpreter`] reads from the head of a native entrypoint. The
1364/// kernel's own `#!` buffer (`BINPRM_BUF_SIZE`) is 256 bytes, so a longer line names nothing the
1365/// kernel would run.
1366const SHEBANG_READ_LIMIT: u64 = 256;
1367
1368/// The interpreter the kernel runs when it execs the script `image`: the first token of a leading
1369/// `#!` line, when that token is an absolute path. `None` for an ELF binary, a relative
1370/// interpreter and a line with no token.
1371///
1372/// Looks at no more than the first [`SHEBANG_READ_LIMIT`] bytes. Only the interpreter itself is
1373/// named: for `#!/usr/bin/env python3` that is `env`, and `python3` is whatever `env` then finds,
1374/// which needs its own `capabilities.shell.allow` entry like any other program a tool runs.
1375pub(crate) fn shebang_interpreter(image: &[u8]) -> Option<PathBuf> {
1376    let head = &image[..image.len().min(SHEBANG_READ_LIMIT as usize)];
1377    let line = crate::reachability::shebang_line_of(head)?;
1378    let interpreter = PathBuf::from(line.split_whitespace().next()?);
1379    interpreter.is_absolute().then_some(interpreter)
1380}
1381
1382/// What the kernel executes to start one installed native tool, derived once, when the runtime
1383/// writes the tool into the session workdir, from the image bytes it writes.
1384///
1385/// The workdir is capsule-writable, so nothing here is read back from it: the `#!` interpreter and
1386/// the `PT_INTERP`/`DT_NEEDED` entries come from the runtime's own copy of the image, the
1387/// dependency walk searches only absolute locations and neither grants nor reads a file under the
1388/// accessible workdir, and the entrypoint's own grant is pinned to the inode the runtime wrote. A
1389/// symlink or any other existing file put in the entrypoint's place loses its `Execute` rule. A
1390/// capsule that rewrites the entrypoint runs its own code under these grants, including when it
1391/// deletes the file and the filesystem hands a new one the freed inode number.
1392#[derive(Debug, Clone, PartialEq, Eq)]
1393pub(crate) struct NativeEntrypoint {
1394    path: PathBuf,
1395    pinned: Option<FileIdentity>,
1396    interpreter: Option<PathBuf>,
1397    closure: Vec<PathBuf>,
1398}
1399
1400impl NativeEntrypoint {
1401    /// Derives the grants for the tool the runtime has just written at `path` from `image`, the
1402    /// bytes it wrote. `accessible_workdir` is the capsule-writable tree the walk stays out of.
1403    pub(crate) fn derive(path: &Path, image: &[u8], accessible_workdir: &Path) -> NativeEntrypoint {
1404        let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1405            .iter()
1406            .map(PathBuf::from)
1407            .collect();
1408        Self::derive_in(path, image, accessible_workdir, &search_dirs)
1409    }
1410
1411    fn derive_in(
1412        path: &Path,
1413        image: &[u8],
1414        accessible_workdir: &Path,
1415        search_dirs: &[PathBuf],
1416    ) -> NativeEntrypoint {
1417        let interpreter = shebang_interpreter(image);
1418        let mut seeds: Vec<PathBuf> = interpreter.iter().cloned().collect();
1419        if let Some(deps) = parse_elf_dependencies(image) {
1420            // Only absolute locations are searched. `$ORIGIN` is the entrypoint's own directory in
1421            // the capsule-writable workdir, and a relative entry resolves against this process's
1422            // working directory, which may be the workdir too: a symlink planted there would
1423            // carry the lookup to any host file, and canonicalising it hides that from the
1424            // exclusion below. A library the tool ships beside itself is readable through the
1425            // workdir's own rule and needs no grant.
1426            let absolute = |entry: &&String| Path::new(entry.as_str()).is_absolute();
1427            seeds.extend(deps.interp.iter().filter(absolute).map(PathBuf::from));
1428            let runpaths: Vec<String> = deps
1429                .runpaths
1430                .iter()
1431                .filter(|entry| absolute(entry) && !entry.contains("ORIGIN"))
1432                .cloned()
1433                .collect();
1434            seeds.extend(
1435                deps.needed
1436                    .iter()
1437                    .filter(|soname| !soname.contains('/') || absolute(soname))
1438                    .filter_map(|soname| {
1439                        resolve_soname(soname, Path::new("/"), &runpaths, search_dirs)
1440                    }),
1441            );
1442        }
1443        let excluded = std::fs::canonicalize(accessible_workdir)
1444            .unwrap_or_else(|_| accessible_workdir.to_path_buf());
1445        NativeEntrypoint {
1446            path: path.to_path_buf(),
1447            pinned: pinned_identity(path),
1448            interpreter,
1449            closure: resolve_landlock_grants_outside(&seeds, search_dirs, &[excluded]),
1450        }
1451    }
1452
1453    /// The installed entrypoint, the path the tool is spawned from.
1454    pub(crate) fn path(&self) -> &Path {
1455        &self.path
1456    }
1457}
1458
1459/// The identity of the regular file at `path`, not following a final symlink. `None` when there is
1460/// none, which leaves the entrypoint without a grant rather than with an unpinned one.
1461#[cfg(unix)]
1462fn pinned_identity(path: &Path) -> Option<FileIdentity> {
1463    std::fs::symlink_metadata(path)
1464        .ok()
1465        .filter(std::fs::Metadata::is_file)
1466        .map(|meta| FileIdentity::of(&meta))
1467}
1468
1469#[cfg(not(unix))]
1470fn pinned_identity(_path: &Path) -> Option<FileIdentity> {
1471    None
1472}
1473
1474/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
1475/// root's *required* read-only binds.
1476///
1477/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
1478/// way that function filters. Both differences follow from the same fact — these fail closed:
1479///
1480///   * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
1481///     Dropping it would be sound for an optional bind (the fixed list already covers it) but it
1482///     would quietly convert a required grant into a dependency on an unrelated list staying the
1483///     way it is today. `plan_composed_root` runs the required loop first and deduplicates by
1484///     target, so naming an already-covered path costs one registration and stays required.
1485///   * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
1486///     grant naming a tree this host does not have refuses the launch — see
1487///     `sealed::PlanBuilder::require_bind`.
1488///
1489/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
1490/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
1491/// layer that rejects one.
1492///
1493/// Pure and syscall-free, so it is unit-testable on any platform.
1494pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1495    let mut dirs: Vec<PathBuf> = Vec::new();
1496    for grant in &policy.shell_staged_runtime {
1497        let path = PathBuf::from(&grant.source_path);
1498        if !path.is_absolute() || dirs.contains(&path) {
1499            continue;
1500        }
1501        dirs.push(path);
1502    }
1503    dirs
1504}
1505
1506/// One listable [`LandlockGrant`] per staged runtime directory.
1507///
1508/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
1509/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
1510/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
1511/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
1512/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
1513/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
1514/// working at all.
1515///
1516/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
1517/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
1518/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
1519/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
1520/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
1521/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
1522/// same inode reached through the bind inside the root.
1523///
1524/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
1525/// of two independent reasons the tree cannot be mutated from inside a session.
1526///
1527/// Pure and syscall-free, so it is unit-testable on any platform.
1528pub(crate) fn resolve_staged_runtime_landlock_grants(
1529    policy: &CapabilityPolicy,
1530) -> Vec<LandlockGrant> {
1531    resolve_staged_runtime_dirs(policy)
1532        .into_iter()
1533        .map(|path| LandlockGrant {
1534            path,
1535            list_dir: true,
1536            executable: true,
1537            pinned: None,
1538        })
1539        .collect()
1540}
1541
1542/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1543/// `KernelSealed`.
1544///
1545/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1546/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1547/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1548/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1549/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1550/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1551/// enumerable.
1552///
1553/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1554/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1555///
1556/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1557/// the manifest never named, so it must enumerate them without becoming permission to run them.
1558///
1559/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1560/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1561/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1562/// nothing. Empty on every other tier.
1563///
1564/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1565/// to open), so unit-testable on every platform.
1566pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1567    if tier != EnforcementTier::KernelSealed {
1568        return Vec::new();
1569    }
1570    crate::sealed::SEALED_RUNTIME_PATHS
1571        .iter()
1572        .map(|path| LandlockGrant {
1573            path: PathBuf::from(path),
1574            list_dir: true,
1575            executable: false,
1576            pinned: None,
1577        })
1578        .collect()
1579}
1580
1581/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1582/// `KernelSealed`.
1583///
1584/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1585/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1586/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1587/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1588/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1589/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1590/// fail certificate verification over an otherwise-working TCP connection.
1591///
1592/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1593/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1594/// directories, regular files and symlinks.
1595/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1596/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1597/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1598///
1599/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1600/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1601/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1602/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1603/// prevent.
1604///
1605/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1606/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1607///
1608/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1609/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1610/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1611/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1612/// reading it reveals nothing sensitive. Empty on every other tier.
1613/// The trust store alone is granted on every Landlock tier, under the network gate, by
1614/// [`resolve_trust_store_grants`].
1615///
1616/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1617/// open), so unit-testable on every platform.
1618pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1619    if tier != EnforcementTier::KernelSealed {
1620        return Vec::new();
1621    }
1622    crate::sealed::SEALED_ETC_PATHS
1623        .iter()
1624        .map(|entry| LandlockGrant {
1625            path: PathBuf::from(entry.path),
1626            list_dir: entry.directory,
1627            executable: false,
1628            pinned: None,
1629        })
1630        .collect()
1631}
1632
1633// ---- fixed capsule device set ------------------------------------------------------------
1634//
1635// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1636// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1637// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1638// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1639// device, and none that takes one away.
1640//
1641// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1642// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1643// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1644// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1645
1646/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1647///
1648/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1649/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1650/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1651/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1652/// `list_dir` into a third meaning.
1653///
1654/// `writable` is the whole reason this type exists as more than a path list:
1655///
1656///   - `false` → `ReadFile` only. The device is readable and nothing more.
1657///   - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, which with the
1658///     session's temporary folder is one of the two writable paths outside the workdir.
1659///
1660/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1661/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1662/// module's Linux-only surface carries.
1663#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1665pub(crate) struct CapsuleDeviceGrant {
1666    pub(crate) path: &'static str,
1667    pub(crate) writable: bool,
1668}
1669
1670/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1671/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1672/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1673/// stays denied by the same mechanism that already denies every other unlisted path: with
1674/// `handle_access` declaring the full right-set of the ABI the ruleset is built at, "no rule" means
1675/// "denied", not "ungoverned".
1676///
1677///   - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1678///     outside the workdir is writable". A read-only grant would be broken, not merely strict:
1679///     bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1680///     shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1681///     and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1682///     write on a character device whose write side is defined to discard everything gives up no
1683///     confidentiality and no integrity — there is no state behind it to reach.
1684///     (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1685///     file is actually created, and `/dev/null` already exists.)
1686///   - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1687///     in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1688///     write there, so it does not get `WriteFile`.
1689///   - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1690///     filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1691///     device outright and fall over if they cannot.
1692///
1693/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1694/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1695/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1696/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1697/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1698/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1699///
1700/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1701/// workload has been observed failing on the missing device, and record that failure in the same
1702/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1703///
1704/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1705/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1706/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1707/// implementation should *retire* this constant, not merge with it.
1708#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1709pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1710    CapsuleDeviceGrant {
1711        path: "/dev/null",
1712        writable: true,
1713    },
1714    CapsuleDeviceGrant {
1715        path: "/dev/zero",
1716        writable: false,
1717    },
1718    CapsuleDeviceGrant {
1719        path: "/dev/urandom",
1720        writable: false,
1721    },
1722];
1723
1724/// Network decision for one destination IP.
1725/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1726/// no reason to open any subprocess socket).
1727#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1728pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1729    network_allow_ips.contains(&ip)
1730}
1731
1732// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1733// rather than `libc::AF_*`: the filter these feed is
1734// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1735// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1736// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1737// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1738// of the three from `libc` and two from literals would be worse than taking all three the same
1739// way. Values are stable kernel ABI: `include/linux/socket.h`.
1740const LINUX_AF_UNIX: i32 = 1;
1741const LINUX_AF_INET: i32 = 2;
1742const LINUX_AF_INET6: i32 = 10;
1743const LINUX_AF_NETLINK: i32 = 16;
1744const LINUX_AF_PACKET: i32 = 17;
1745
1746/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1747/// `capabilities.network.unix_sockets` declaration.
1748///
1749/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1750/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1751/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1752///
1753/// Why a domain check is a classic BPF rule and not a notify:
1754/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1755/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1756/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1757/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1758/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1759/// pointed-to argument out of another task's memory invites.
1760///
1761/// The three families, and why they are not symmetric:
1762///   - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1763///     by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1764///     need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1765///     Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1766///   - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1767///     capture and injection) are denied unconditionally, with no manifest key to widen them.
1768///     Neither has a shell-tool use case comparable to a local daemon socket, and both are
1769///     strictly more dangerous than one. Widening them would be a deliberate future decision with
1770///     its own justification, not an oversight to be fixed by adding a flag here.
1771///
1772/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1773/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1774/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1775/// get a positive rule — since the filter's default action is a deny, a family named by neither
1776/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1777#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1778pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1779    let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1780    if !unix_sockets_allowed {
1781        denied.push(LINUX_AF_UNIX);
1782    }
1783    denied
1784}
1785
1786/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1787/// `capabilities.network.unix_sockets` declaration. The exact complement of
1788/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1789///
1790/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1791/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1792/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1793/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1794/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1795/// equality-matched sets that a test can check against each other.
1796///
1797/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1798/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1799/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1800/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1801/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1802/// reason) if a real workload needs it.
1803#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1804pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1805    let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1806    if unix_sockets_allowed {
1807        allowed.push(LINUX_AF_UNIX);
1808    }
1809    allowed
1810}
1811
1812/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1813/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1814///
1815/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1816/// reason: the filter is always compiled for a Linux child.
1817#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1818const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1819#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1820const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1821
1822/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1823/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1824/// is the maintained source of the same list Docker ships).
1825///
1826/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1827/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1828///
1829///   - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1830///     [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1831///
1832/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1833/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1834/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1835/// child's very first act after this filter loads is the `execve` that turns it into the tool
1836/// binary, so a denial here refuses every capsule outright.
1837///
1838/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1839/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1840/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1841/// `io_uring` is the one that
1842/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1843/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1844/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1845///
1846/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1847/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1848/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1849/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1850/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1851/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1852#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1853pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1854    // ---- process / thread lifecycle ----
1855    // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1856    // doc comment for the mechanism that replaced their former `Notify` rules.
1857    "execve",
1858    "execveat",
1859    // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1860    // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1861    // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1862    // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1863    // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1864    // largely inert.
1865    "clone",
1866    "clone3",
1867    "fork",
1868    "vfork",
1869    "exit",
1870    "exit_group",
1871    "wait4",
1872    "waitid",
1873    "kill",
1874    "tkill",
1875    "tgkill",
1876    "rt_sigaction",
1877    "rt_sigprocmask",
1878    "rt_sigreturn",
1879    "rt_sigsuspend",
1880    "rt_sigpending",
1881    "rt_sigtimedwait",
1882    "rt_sigqueueinfo",
1883    "rt_tgsigqueueinfo",
1884    "sigaltstack",
1885    // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1886    // would break every `sleep`/`read` that takes a signal.
1887    "restart_syscall",
1888    "pause",
1889    "set_tid_address",
1890    "set_robust_list",
1891    "get_robust_list",
1892    // glibc 2.35+ registers a restartable sequence on every thread start.
1893    "rseq",
1894    "prctl",
1895    "arch_prctl",
1896    "getpid",
1897    "getppid",
1898    "gettid",
1899    "getpgrp",
1900    "getpgid",
1901    "setpgid",
1902    "getsid",
1903    "setsid",
1904    "getpriority",
1905    "setpriority",
1906    "ioprio_get",
1907    "ioprio_set",
1908    "sched_yield",
1909    "sched_getaffinity",
1910    "sched_setaffinity",
1911    "sched_getparam",
1912    "sched_setparam",
1913    "sched_getscheduler",
1914    "sched_setscheduler",
1915    "sched_getattr",
1916    "sched_setattr",
1917    "sched_get_priority_max",
1918    "sched_get_priority_min",
1919    "sched_rr_get_interval",
1920    "getcpu",
1921    "capget",
1922    "capset",
1923    "prlimit64",
1924    "getrlimit",
1925    "setrlimit",
1926    "getrusage",
1927    "getitimer",
1928    "setitimer",
1929    "times",
1930    "uname",
1931    "sysinfo",
1932    // pidfd process handles: they only ever address a process the caller could already signal.
1933    "pidfd_open",
1934    "pidfd_send_signal",
1935    // ---- memory ----
1936    "mmap",
1937    "munmap",
1938    "mprotect",
1939    "mremap",
1940    "madvise",
1941    "brk",
1942    "mlock",
1943    "mlock2",
1944    "munlock",
1945    "mlockall",
1946    "munlockall",
1947    "membarrier",
1948    "msync",
1949    "mincore",
1950    "memfd_create",
1951    "pkey_alloc",
1952    "pkey_free",
1953    "pkey_mprotect",
1954    "shmget",
1955    "shmat",
1956    "shmdt",
1957    "shmctl",
1958    // ---- file I/O ----
1959    "open",
1960    "openat",
1961    "openat2",
1962    "creat",
1963    "close",
1964    "close_range",
1965    "read",
1966    "write",
1967    "pread64",
1968    "pwrite64",
1969    "readv",
1970    "writev",
1971    "preadv",
1972    "pwritev",
1973    "preadv2",
1974    "pwritev2",
1975    "lseek",
1976    "dup",
1977    "dup2",
1978    "dup3",
1979    "fcntl",
1980    "flock",
1981    "fsync",
1982    "fdatasync",
1983    "sync",
1984    "syncfs",
1985    "sync_file_range",
1986    "fadvise64",
1987    "readahead",
1988    "truncate",
1989    "ftruncate",
1990    "fallocate",
1991    "stat",
1992    "fstat",
1993    "lstat",
1994    "newfstatat",
1995    "statx",
1996    "access",
1997    "faccessat",
1998    "faccessat2",
1999    "getdents",
2000    "getdents64",
2001    "getcwd",
2002    "chdir",
2003    "fchdir",
2004    "mkdir",
2005    "mkdirat",
2006    "rmdir",
2007    "unlink",
2008    "unlinkat",
2009    "rename",
2010    "renameat",
2011    "renameat2",
2012    "link",
2013    "linkat",
2014    "symlink",
2015    "symlinkat",
2016    "readlink",
2017    "readlinkat",
2018    "chmod",
2019    "fchmod",
2020    "fchmodat",
2021    // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
2022    // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
2023    "fchmodat2",
2024    "chown",
2025    "fchown",
2026    "fchownat",
2027    "lchown",
2028    "umask",
2029    "utime",
2030    "utimes",
2031    "utimensat",
2032    "futimesat",
2033    // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
2034    // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
2035    // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
2036    "mknod",
2037    "mknodat",
2038    "sendfile",
2039    "copy_file_range",
2040    "splice",
2041    "tee",
2042    "vmsplice",
2043    "ioctl",
2044    "poll",
2045    "ppoll",
2046    "select",
2047    "pselect6",
2048    "epoll_create",
2049    "epoll_create1",
2050    "epoll_ctl",
2051    "epoll_wait",
2052    "epoll_pwait",
2053    "epoll_pwait2",
2054    "eventfd",
2055    "eventfd2",
2056    "pipe",
2057    "pipe2",
2058    "inotify_init",
2059    "inotify_init1",
2060    "inotify_add_watch",
2061    "inotify_rm_watch",
2062    "signalfd",
2063    "signalfd4",
2064    "timerfd_create",
2065    "timerfd_settime",
2066    "timerfd_gettime",
2067    "getxattr",
2068    "lgetxattr",
2069    "fgetxattr",
2070    "setxattr",
2071    "lsetxattr",
2072    "fsetxattr",
2073    "listxattr",
2074    "llistxattr",
2075    "flistxattr",
2076    "removexattr",
2077    "lremovexattr",
2078    "fremovexattr",
2079    "statfs",
2080    "fstatfs",
2081    // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
2082    // into an open file, ignoring the path it was reached by) stays denied.
2083    "name_to_handle_at",
2084    // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
2085    // path resolution of its own, so it carries none of the LSM-bypass property that got the
2086    // `io_uring_*` family denied.
2087    "io_setup",
2088    "io_destroy",
2089    "io_submit",
2090    "io_cancel",
2091    "io_getevents",
2092    // ---- sockets ----
2093    // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
2094    //
2095    // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
2096    // capsule's own network namespace has no route to anything except the egress proxy. The
2097    // destination check lives there, not in this filter — see `crate::network_namespace`.
2098    "connect",
2099    "sendto",
2100    "socketpair",
2101    "bind",
2102    "listen",
2103    "accept",
2104    "accept4",
2105    "getsockname",
2106    "getpeername",
2107    "getsockopt",
2108    "setsockopt",
2109    "shutdown",
2110    "sendmsg",
2111    "sendmmsg",
2112    "recvfrom",
2113    "recvmsg",
2114    "recvmmsg",
2115    // ---- time ----
2116    "clock_gettime",
2117    "clock_getres",
2118    "clock_nanosleep",
2119    "nanosleep",
2120    "gettimeofday",
2121    "time",
2122    "timer_create",
2123    "timer_settime",
2124    "timer_gettime",
2125    "timer_getoverrun",
2126    "timer_delete",
2127    "alarm",
2128    // ---- System V / POSIX IPC ----
2129    "msgget",
2130    "msgsnd",
2131    "msgrcv",
2132    "msgctl",
2133    "semget",
2134    "semop",
2135    "semctl",
2136    "semtimedop",
2137    "mq_open",
2138    "mq_unlink",
2139    "mq_timedsend",
2140    "mq_timedreceive",
2141    "mq_notify",
2142    "mq_getsetattr",
2143    // ---- misc ----
2144    "getrandom",
2145    "futex",
2146    "futex_waitv",
2147    "getuid",
2148    "geteuid",
2149    "getgid",
2150    "getegid",
2151    "setuid",
2152    "setgid",
2153    "setreuid",
2154    "setregid",
2155    "setresuid",
2156    "setresgid",
2157    "getresuid",
2158    "getresgid",
2159    "getgroups",
2160    "setgroups",
2161    "setfsuid",
2162    "setfsgid",
2163];
2164
2165/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
2166/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
2167/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
2168/// allowlist while reconciling it against a newer upstream profile.
2169///
2170/// Three groups, and they are not equally negotiable:
2171///
2172///   1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
2173///      them: `io_uring_setup`/`io_uring_enter`/
2174///      `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
2175///      around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
2176///      `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
2177///      (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
2178///      `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
2179///      `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
2180///      deliberately stricter there.
2181///   2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
2182///      mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
2183///      clock setting, NUMA policy, `fanotify`.
2184///   3. **Denied instead of argument-conditioned.** Docker allows these only for specific
2185///      argument values, which a per-syscall-name allowlist cannot express and which would
2186///      otherwise have to be routed through a userspace decision this filter no longer makes.
2187///      Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
2188///      standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
2189///      itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
2190// The list is a specification, not an input to the filter build: its one consumer is the
2191// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
2192// the thing that test checks against.
2193#[allow(dead_code)]
2194pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2195    // 1. demonstrated reachable on bare metal
2196    "io_uring_setup",
2197    "io_uring_enter",
2198    "io_uring_register",
2199    "bpf",
2200    "userfaultfd",
2201    "perf_event_open",
2202    "process_vm_readv",
2203    "process_vm_writev",
2204    "open_by_handle_at",
2205    "keyctl",
2206    "add_key",
2207    "request_key",
2208    "ptrace",
2209    // 2. privileged / host-state
2210    "mount",
2211    "umount2",
2212    "pivot_root",
2213    "chroot",
2214    "unshare",
2215    "setns",
2216    "init_module",
2217    "finit_module",
2218    "delete_module",
2219    "kexec_load",
2220    "kexec_file_load",
2221    "reboot",
2222    "swapon",
2223    "swapoff",
2224    "acct",
2225    "quotactl",
2226    "nfsservctl",
2227    "iopl",
2228    "ioperm",
2229    "vm86",
2230    "vm86old",
2231    "_sysctl",
2232    "sysfs",
2233    "uselib",
2234    "create_module",
2235    "get_kernel_syms",
2236    "query_module",
2237    "lookup_dcookie",
2238    "clock_adjtime",
2239    "clock_settime",
2240    "settimeofday",
2241    "stime",
2242    "adjtimex",
2243    "set_mempolicy",
2244    "get_mempolicy",
2245    "mbind",
2246    "migrate_pages",
2247    "move_pages",
2248    "fanotify_init",
2249    "fanotify_mark",
2250    // 3. denied instead of argument-conditioned
2251    "seccomp",
2252    "personality",
2253    "kcmp",
2254];
2255
2256/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
2257/// project treats the filter as having outgrown its headroom. Held against the *measured* export
2258/// of the real filter by a test, not against an estimate: growing
2259/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
2260/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
2261///
2262/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
2263/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
2264/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
2265/// number was chosen against.
2266#[allow(dead_code)]
2267pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2268
2269/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
2270/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
2271/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
2272/// two filters that straddle it in forked children.
2273#[allow(dead_code)]
2274pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2275
2276/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
2277/// attached to one task may total, counting every filter it already carries plus a small
2278/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
2279/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
2280/// quoted: a test stacks filters in a forked child until the refusal arrives.
2281#[allow(dead_code)]
2282pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2283
2284/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
2285#[derive(Debug, Clone)]
2286pub(crate) struct ShellEnforcement {
2287    pub(crate) tier: EnforcementTier,
2288    /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
2289    /// by [`resolve_network_allowlist_ips`].
2290    ///
2291    /// The narrower of the egress proxy's two checks — the one applied to a destination the
2292    /// capsule reached without ever resolving a name for it (a hardcoded literal). See
2293    /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
2294    /// [`network_ip_allowed`].
2295    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2296    pub(crate) network_allow_ips: Vec<IpAddr>,
2297    /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
2298    /// checks a name when it resolves it and again when the connection to the address that name
2299    /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
2300    /// many tenants) in either direction.
2301    ///
2302    /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
2303    /// entry cannot mean one thing to a WASM guest and another to a subprocess.
2304    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2305    pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2306    /// The TCP ports the capsule's network namespace opens listeners on, derived from the
2307    /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
2308    /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
2309    /// consulted — the strongest form the refusal can take.
2310    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2311    pub(crate) egress_tcp_ports: Vec<u16>,
2312    /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
2313    /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
2314    /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
2315    /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
2316    /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
2317    /// platform for parity but never read off Linux.
2318    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2319    pub(crate) unix_sockets_allowed: bool,
2320    /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
2321    /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
2322    /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
2323    /// writes into its workdir can run, under any name. Consumed only by
2324    /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
2325    /// for parity but never read off Linux.
2326    ///
2327    /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
2328    /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
2329    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2330    pub(crate) workdir_exec: bool,
2331    /// The Landlock ABI the child's ruleset is built at: [`ruleset_landlock_abi`] of the session's
2332    /// [`HostProbe`] and [`Self::tier`], decided here so the ABI `session_start` records is the one
2333    /// installed. `None` on the tiers with no Landlock domain, and off Linux always.
2334    ///
2335    /// On `KernelFull`/`KernelSealed` a `None` builds at ABI v1, the narrowest set, so a missing
2336    /// reading can only narrow what the capsule may do. `Some(1)` likewise reproduces exactly
2337    /// what a 5.13–5.18 kernel installs, which is how the tests force that ruleset on a newer one.
2338    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2339    pub(crate) landlock_abi: Option<u8>,
2340    /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
2341    /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
2342    /// stdlib. Seven origins, combined here:
2343    ///
2344    ///   - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
2345    ///     shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
2346    ///     (they are individual files, where `ReadDir` was always a no-op);
2347    ///   - one grant per `capabilities.shell.interpreter_runtime` directory, from
2348    ///     `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
2349    ///     declared;
2350    ///   - one non-executable grant per entry a recognised interpreter reported on its import
2351    ///     path at staging, and per library its extension modules need, from
2352    ///     `resolve_derived_interpreter_grants` — listable for a directory;
2353    ///   - one non-executable read grant per host trust-store path staging found for a capsule
2354    ///     with a non-empty `shell.allow` and `network.allow`, from `resolve_trust_store_grants` —
2355    ///     listable for a directory;
2356    ///   - one listable grant per `capabilities.shell.staged_runtime` tree, from
2357    ///     `resolve_staged_runtime_landlock_grants`;
2358    ///   - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
2359    ///     [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
2360    ///     `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
2361    ///     tree, which nothing else grants;
2362    ///   - on `KernelSealed` **only**, one non-executable read grant per fixed
2363    ///     [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
2364    ///     (`list_dir` per entry, since that list mixes directories and files) — the composed
2365    ///     root's `/etc` allowlist, likewise granted by nothing else.
2366    ///
2367    /// The closure, `interpreter_runtime` and `staged_runtime` grants carry `executable: true`;
2368    /// the derived interpreter grants, the trust-store grants and the two tier-issued ones do not,
2369    /// and that asymmetry is load-bearing — see [`LandlockGrant`].
2370    ///
2371    /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
2372    /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
2373    /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
2374    /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
2375    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2376    pub(crate) landlock_grants: Vec<LandlockGrant>,
2377    /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
2378    /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
2379    /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
2380    ///
2381    /// Whole directories, never files: a bind carries a directory's whole contents, so this must
2382    /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
2383    /// every platform for parity, exactly like `landlock_grants` above.
2384    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2385    pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2386    /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
2387    /// read-only into the composed root at its own absolute path.
2388    ///
2389    /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
2390    /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
2391    /// while a staged tree that cannot be mounted aborts the composed-root construction before
2392    /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
2393    /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
2394    /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
2395    /// parity, exactly like the two fields above.
2396    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2397    pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2398    /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
2399    /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
2400    /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
2401    /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
2402    /// half. See [`crate::resources`].
2403    pub(crate) resource_limits: crate::resources::HostResourceLimits,
2404    /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
2405    /// for capsules that can actually spawn a native subprocess). `None` on macOS always, and on
2406    /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
2407    /// spawn one and could not be given a scope never reaches here, because the launch is
2408    /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
2409    ///
2410    /// Its presence decides each spawn's `RLIMIT_NPROC` ([`Self::nproc_bound`]): a spawn that
2411    /// joins it is bounded by `pids.max` alone. A native tool can still be dispatched on a
2412    /// session whose policy required no scope, so a spawn path never assumes one is here.
2413    pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2414    /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
2415    /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
2416    /// only at the next agent turn.
2417    pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2418    /// The session's one network namespace, which every command on a kernel tier joins.
2419    ///
2420    /// Shared by every clone of this enforcement, so a listener one command starts is reachable
2421    /// from the next. A fresh, not-yet-created namespace from [`Self::resolve`] and
2422    /// [`Self::environment_only`]; a plan inside a session takes its session's through
2423    /// [`Self::with_session_netns`]. Released by [`Self::end_session`].
2424    pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2425    /// The session's runtime directory and its `sealed-etc/` sibling: `$MURMUR_RUNTIME_DIR` in
2426    /// every command's environment, a read-only Landlock rule on the Landlock tiers, a read-only
2427    /// bind on `KernelSealed`, and the source of the composed root's `/etc/passwd` and
2428    /// `/etc/group`. `None` outside a session, where a command is told of no runtime directory
2429    /// and a sealed root cannot be composed.
2430    pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2431    /// The session's runtime directory itself, removed by [`Self::remove_runtime_dir`] on the
2432    /// process exits that run no destructor. Set only on the session's own enforcement.
2433    pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2434    /// How a literal `/tmp` reaches the session's temporary folder (`runtime_dir`'s `temp`):
2435    /// the session's [`crate::temp_dir::temp_mapping`] answer, through [`Self::with_temp_mapping`],
2436    /// or [`crate::temp_dir::tier_temp_mapping`] of [`Self::tier`] until one is handed in. Read
2437    /// only while the enforcement holds a folder.
2438    pub(crate) temp_mapping: crate::temp_dir::TempMapping,
2439}
2440
2441impl ShellEnforcement {
2442    /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
2443    ///
2444    /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
2445    /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
2446    /// caller creates and attaches with [`Self::with_host_bounding`].
2447    ///
2448    /// `decision` is the session's [`crate::containment::ContainmentDecision`], and its applied
2449    /// tier is the one installed: whether a sealed-capable host builds a composed root is decided
2450    /// there, from the declared floor, so the tier installed here is the one the session's trace
2451    /// names.
2452    ///
2453    /// `probe` is the reading `decision` was made from — for a session, the [`HostProbe`]
2454    /// `runtime::stage_session` took and carried on the `StagedSession` — and supplies the
2455    /// kernel's Landlock ABI.
2456    pub(crate) fn resolve(
2457        policy: &CapabilityPolicy,
2458        decision: &crate::containment::ContainmentDecision,
2459        probe: HostProbe,
2460    ) -> Result<Self, String> {
2461        let tier = decision.applied_tier();
2462        let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2463        let network_allow_rules =
2464            crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2465                .map_err(|error| error.to_string())?;
2466        let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2467        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
2468        // The `DT_NEEDED`-closure files (individual files → non-listable), one grant per
2469        // author-declared `interpreter_runtime` directory (each with its own `list_dir`), and one
2470        // read-only grant per entry a recognised interpreter reported on its own import path at
2471        // staging. A directory neither the manifest names nor an allowlisted interpreter reports
2472        // never receives a rule, regardless of what it holds.
2473        let mut landlock_grants =
2474            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
2475        landlock_grants.extend(resolve_interpreter_runtime_grants(
2476            &policy.shell_interpreter_runtime,
2477        ));
2478        landlock_grants.extend(resolve_derived_interpreter_grants(
2479            &policy.shell_derived_interpreter_runtime,
2480        ));
2481        // Plus one read-only grant per host trust-store path staging found. Not tier-gated: the
2482        // network gate lives in the derivation, which leaves the field empty for a capsule whose
2483        // commands cannot reach the network.
2484        landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2485        // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
2486        // composed root and then denied by the Landlock ruleset installed inside it — see
2487        // `resolve_staged_runtime_landlock_grants`.
2488        landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2489        // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
2490        // directory — the composed root binds them but Landlock inside the root still denies
2491        // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
2492        // `scoped` capsule runs on) the same grant would expose real host directory shape. The
2493        // resolver applies that gate itself and returns nothing on every other tier.
2494        landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2495        // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
2496        // mounted-but-denied bug one directory over, and the reason TLS certificate verification
2497        // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
2498        // `KernelFull` these rules would apply to the *host's* /etc.
2499        landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2500        let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2501        let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2502        Ok(Self {
2503            tier,
2504            network_allow_ips,
2505            network_allow_rules,
2506            egress_tcp_ports,
2507            unix_sockets_allowed: policy.unix_sockets_allowed,
2508            workdir_exec: policy.workdir_exec_allowed,
2509            landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2510            landlock_grants,
2511            sealed_bind_dirs,
2512            staged_runtime_dirs,
2513            resource_limits: policy.resources,
2514            cgroup_scope: None,
2515            workdir_guard: None,
2516            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2517            runtime_dir: None,
2518            runtime_dir_owner: None,
2519            temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2520        })
2521    }
2522
2523    /// [`Self::resolve`] for a capsule declaring `declared`, decided from `probe` with the
2524    /// runtime directory outside the workdir: the seam tests resolve an enforcement through.
2525    #[cfg(test)]
2526    pub(crate) fn resolve_declared(
2527        policy: &CapabilityPolicy,
2528        declared: murmur_artifact::ContainmentClass,
2529        probe: HostProbe,
2530    ) -> Result<Self, String> {
2531        let decision = crate::containment::ContainmentDecision::decide(
2532            probe.tier(),
2533            declared,
2534            policy.workdir_exec_allowed,
2535            false,
2536        );
2537        Self::resolve(policy, &decision, probe)
2538    }
2539
2540    /// Attach the session's runtime directory, and with `owner` the directory itself, so a
2541    /// process exit that runs no destructor removes it ([`Self::remove_runtime_dir`]).
2542    pub(crate) fn with_runtime_dir(
2543        mut self,
2544        roots: crate::runtime_dir::RuntimeDirRoots,
2545        owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2546    ) -> Self {
2547        self.runtime_dir = Some(roots);
2548        self.runtime_dir_owner = owner;
2549        self
2550    }
2551
2552    /// The runtime directory's absolute path, the value of `$MURMUR_RUNTIME_DIR`.
2553    pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2554        self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2555    }
2556
2557    /// The session's records directory, held. `None` for an enforcement held outside a session.
2558    pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2559        self.runtime_dir
2560            .as_ref()
2561            .and_then(|roots| roots.records.as_ref())
2562    }
2563
2564    /// The records directory's absolute path, the value of `$MURMUR_RECORDS_DIR`. `None` for an
2565    /// enforcement held outside a session.
2566    pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2567        self.records_root()
2568            .map(crate::workdir_fs::WorkdirRoot::path)
2569    }
2570
2571    /// The session's temporary folder's absolute path. `None` for an enforcement held outside a
2572    /// session.
2573    pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2574        self.runtime_dir
2575            .as_ref()
2576            .and_then(|roots| roots.temp.as_ref())
2577            .map(crate::workdir_fs::WorkdirRoot::path)
2578    }
2579
2580    /// The session's home folder, `home/` in its records directory, held. `None` for an
2581    /// enforcement held outside a session, which runs no command.
2582    pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2583        self.runtime_dir
2584            .as_ref()
2585            .and_then(|roots| roots.home.as_ref())
2586    }
2587
2588    /// The home folder's absolute path, the value of `$HOME` and the synthetic passwd's home
2589    /// directory on every tier. `None` for an enforcement held outside a session.
2590    pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2591        self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2592    }
2593
2594    /// Hand in the session's [`crate::temp_dir::temp_mapping`] answer, decided for this
2595    /// enforcement's tier.
2596    pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2597        self.temp_mapping = mapping;
2598        self
2599    }
2600
2601    /// What this enforcement installs for `/tmp` while it holds a folder: the composed root's bind
2602    /// on `KernelSealed`, a private mount namespace on `KernelFull` and `KernelSeccompOnly` when
2603    /// [`Self::temp_mapping`] says so, and nothing otherwise. Keyed on [`Self::tier`] as well, so
2604    /// no tier is ever told of a mechanism it does not install.
2605    pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2606        use crate::temp_dir::TempMapping;
2607        match (self.tier, self.temp_mapping) {
2608            (
2609                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2610                TempMapping::MountNamespace,
2611            ) => TempMapping::MountNamespace,
2612            (
2613                EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2614                TempMapping::NotMapped(reason),
2615            ) => TempMapping::NotMapped(reason),
2616            (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2617        }
2618    }
2619
2620    /// The value of `TMPDIR`, `TMP` and `TEMP` in every command this enforcement spawns: `/tmp`
2621    /// when a literal `/tmp` is the session's temporary folder, the folder's absolute path when
2622    /// not, and `None` when the enforcement holds no folder.
2623    pub(crate) fn tmpdir_value(&self) -> Option<String> {
2624        self.temp_dir_path()
2625            .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2626    }
2627
2628    /// Removes the session's runtime directory now, ahead of a process exit that runs no
2629    /// destructor. Does nothing on an enforcement that does not own one.
2630    pub(crate) fn remove_runtime_dir(&self) {
2631        if let Some(owner) = &self.runtime_dir_owner {
2632            owner.remove();
2633        }
2634    }
2635
2636    /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
2637    /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
2638    ///
2639    /// Separate from [`Self::resolve`] because both are *live* per-session resources with
2640    /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
2641    /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
2642    /// creation so the fail-closed launch refusal can happen with a typed error before this is
2643    /// ever reached.
2644    pub(crate) fn with_host_bounding(
2645        mut self,
2646        cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2647        workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2648    ) -> Self {
2649        self.cgroup_scope = cgroup_scope;
2650        self.workdir_guard = workdir_guard;
2651        self
2652    }
2653
2654    /// The `RLIMIT_NPROC` decision for one spawn, made in the parent before its `pre_exec`
2655    /// closure is installed.
2656    ///
2657    /// [`crate::resources::NprocBound::CgroupPidsMax`] when the spawn will join a scope that
2658    /// places it, because both closures that receive this run the join fatally before `execve`.
2659    /// Otherwise [`crate::resources::NprocBound::Headroom`], with a baseline measured by
2660    /// `crate::resources::uid_task_count` on this call — a `/proc` walk on Linux — so call it
2661    /// once per spawn and never from inside `pre_exec`.
2662    pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2663        if self
2664            .cgroup_scope
2665            .as_ref()
2666            .is_some_and(|scope| scope.places_subprocesses())
2667        {
2668            crate::resources::NprocBound::CgroupPidsMax
2669        } else {
2670            crate::resources::NprocBound::Headroom {
2671                baseline: crate::resources::uid_task_count().unwrap_or(0),
2672            }
2673        }
2674    }
2675
2676    /// The latched workdir-size breach, if the guard has seen one.
2677    pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2678        self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2679    }
2680
2681    /// Refuse to spawn once the workdir ceiling has been crossed.
2682    ///
2683    /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
2684    /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
2685    /// the periodic check notices the breach, and the very next subprocess never starts.
2686    pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2687        match self.workdir_breach() {
2688            Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2689            None => Ok(()),
2690        }
2691    }
2692
2693    /// The permanent macOS/non-Linux value, and also what tests should construct directly to
2694    /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
2695    /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
2696    /// test code calls this directly today — production always goes through `resolve()`,
2697    /// which independently arrives at the same tier value on non-Linux hosts.)
2698    #[cfg_attr(not(test), allow(dead_code))]
2699    pub(crate) fn environment_only() -> Self {
2700        Self {
2701            tier: EnforcementTier::EnvironmentOnly,
2702            network_allow_ips: Vec::new(),
2703            network_allow_rules: Vec::new(),
2704            egress_tcp_ports: Vec::new(),
2705            // Denied, matching every other grant this constructor zeroes out. Inert on this tier
2706            // (no seccomp filter is installed at all), but it must not read as "allowed".
2707            unix_sockets_allowed: false,
2708            // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
2709            // Landlock domain exists to withhold anything from), but it must not read as "allowed".
2710            workdir_exec: false,
2711            // No Landlock domain on this tier. A test that spreads this over a Landlock tier
2712            // builds at ABI v1, the narrowest set.
2713            landlock_abi: None,
2714            landlock_grants: Vec::new(),
2715            sealed_bind_dirs: Vec::new(),
2716            staged_runtime_dirs: Vec::new(),
2717            // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
2718            // zeroing them out here would misrepresent what a real macOS host does.
2719            resource_limits: crate::resources::HostResourceLimits::default(),
2720            cgroup_scope: None,
2721            workdir_guard: None,
2722            session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2723            runtime_dir: None,
2724            runtime_dir_owner: None,
2725            temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2726        }
2727    }
2728
2729    /// This enforcement holding a session's runtime directory roots, home folder included, in a
2730    /// fresh directory kept past the test: a clone may outlive the value. For a test whose
2731    /// commands need only to run.
2732    #[cfg(test)]
2733    pub(crate) fn with_test_session_dirs(self) -> Self {
2734        let base = tempfile::tempdir().unwrap().keep();
2735        self.with_runtime_dir(test_runtime_dir(&base), None)
2736    }
2737
2738    /// Run this enforcement's commands in `session_netns` rather than in a namespace of its own.
2739    ///
2740    /// For a plan submitted inside a session: its shell steps are that session's commands, so
2741    /// they share its loopback and its egress proxy.
2742    pub(crate) fn with_session_netns(
2743        mut self,
2744        session_netns: Arc<crate::network_namespace::SessionNetns>,
2745    ) -> Self {
2746        self.session_netns = session_netns;
2747        self
2748    }
2749
2750    /// This enforcement for one native tool spawn: a clone whose exec allowlist also holds what
2751    /// the kernel itself executes to start `entrypoint`.
2752    ///
2753    /// That is the entrypoint, pinned to the inode the runtime installed, and the closure
2754    /// [`NativeEntrypoint`] derived from the installed image: the absolute interpreter on its `#!`
2755    /// line, and the `PT_INTERP`/`DT_NEEDED` closure — the native-tool counterpart of what
2756    /// `capabilities.shell.allow` derives for a shell binary. The entrypoint needs its own
2757    /// `Execute` rule because it sits in the runtime directory, whose rule grants reading and
2758    /// nothing else. On `KernelSealed` the interpreter's directory is also bound into the composed
2759    /// root when no fixed runtime path carries it; the entrypoint needs no bind, because the root
2760    /// binds the runtime directory at its own path.
2761    ///
2762    /// Nothing else is added, and nothing is read from the workdir here. Whatever the tool reads
2763    /// or runs once started is granted by the capsule, exactly as for a shell command. Every clone
2764    /// shares `session_netns`, so the tool joins the session's one namespace and egress proxy.
2765    pub(crate) fn for_native_entrypoint(&self, entrypoint: &NativeEntrypoint) -> ShellEnforcement {
2766        let mut enforcement = self.clone();
2767        if let Some(pinned) = entrypoint.pinned {
2768            enforcement.landlock_grants.push(LandlockGrant {
2769                path: entrypoint.path.clone(),
2770                list_dir: false,
2771                executable: true,
2772                pinned: Some(pinned),
2773            });
2774        }
2775        enforcement
2776            .landlock_grants
2777            .extend(LandlockGrant::non_listable_files(
2778                entrypoint.closure.clone(),
2779            ));
2780        if self.tier == EnforcementTier::KernelSealed {
2781            if let Some(dir) = entrypoint.interpreter.as_deref().and_then(Path::parent) {
2782                push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
2783            }
2784        }
2785        enforcement
2786    }
2787
2788    /// End the session: kill every process it started, then release its network namespace.
2789    ///
2790    /// Killing goes through the cgroup scope, which every spawn joins before `execve` and no fork
2791    /// escapes, so a server backgrounded with `&`, a `setsid` double fork and a demoted command
2792    /// still running are all in it. The namespace is released only once the scope is empty (or
2793    /// [`SESSION_END_KILL_WAIT`] has passed), so its proxy never stops under a process still
2794    /// using it. Idempotent; every spawn on this enforcement afterwards is refused.
2795    pub(crate) fn end_session(&self) {
2796        self.kill_session_processes();
2797        self.session_netns.end();
2798    }
2799
2800    /// Kill every process in this enforcement's cgroup scope and wait, bounded by
2801    /// [`SESSION_END_KILL_WAIT`], for the scope to empty. Does nothing without a scope.
2802    ///
2803    /// The half of [`Self::end_session`] a plan runs at its own end: a plan's scope is its own,
2804    /// and the namespace may be its session's.
2805    pub(crate) fn kill_session_processes(&self) {
2806        let Some(scope) = self.cgroup_scope.as_ref() else {
2807            return;
2808        };
2809        if let Err(error) = scope.kill_all() {
2810            crate::runtime_err!(
2811                "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
2812                 {error}",
2813                scope.path().display()
2814            );
2815        }
2816        if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
2817            crate::runtime_err!(
2818                "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
2819                 they were killed",
2820                scope.path().display(),
2821                SESSION_END_KILL_WAIT.as_millis()
2822            );
2823        }
2824    }
2825}
2826
2827/// How long [`ShellEnforcement::end_session`] waits for a killed scope's `cgroup.procs` to empty.
2828pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
2829
2830/// Calls [`ShellEnforcement::end_session`] when dropped.
2831///
2832/// Held by a launch for its whole length, so a session that ends by an error return still kills
2833/// what it started. Every path that ends a session normally calls `end_session` itself first, at
2834/// the point its teardown needs it; the call this makes then finds nothing left to do.
2835pub(crate) struct EndSessionOnDrop(ShellEnforcement);
2836
2837impl EndSessionOnDrop {
2838    pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
2839        Self(enforcement)
2840    }
2841}
2842
2843impl Drop for EndSessionOnDrop {
2844    fn drop(&mut self) {
2845        self.0.end_session();
2846    }
2847}
2848
2849/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
2850/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
2851/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
2852/// one or two concise sentences.
2853///
2854/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
2855/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
2856/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
2857/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
2858/// dynamically link, and the only writable paths outside the workdir are the session's temporary
2859/// and home folders and `/dev/null`, one of the three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp
2860/// additionally refuses the `socket(2)` domains in `denied_socket_domains` — closing the
2861/// `/var/run/docker.sock` path, which no Landlock ABI mediates for pathname sockets. The forked
2862/// shell child also drops every Linux capability before `execve` (see
2863/// `linux_enforce::drop_all_capabilities`).
2864/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
2865/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
2866const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
2867resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
2868derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
2869shared libraries — nothing writable), read-only access to the directories an allowlisted \
2870CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
2871trust store when capabilities.network.allow is also non-empty, and no other directory granted \
2872wholesale, plus a fixed device set \
2873every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
2874folders, the only writable paths outside the workdir), \
2875/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
2876raw block devices stay denied. The workdir's own grant also \
2877withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
2878capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
2879capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
2880defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
2881profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
2882and the forked shell child \
2883drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
2884denied, not absent: stat, access and readlink on an ungranted path still succeed.";
2885
2886/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
2887/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
2888/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
2889const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
2890host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
2891namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
2892its temporary folder at /tmp and its home folder at $HOME the only writable paths, a private /dev \
2893tmpfs carrying the OCI default device set), with Landlock \
2894and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
2895rather than denied — with one documented exception: /proc is a bind of the host's, not a masked \
2896private procfs, because mounting one unprivileged needs a PID namespace this tier does not create, \
2897so host process metadata stays visible there exactly as it is under scoped.";
2898
2899const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
2900lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
2901kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
2902verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
2903read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
2904mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
2905exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
2906capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
2907rule needs no Landlock and so applies identically on this tier, but it has not been verified \
2908either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
2909profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
2910tier too. The forked shell child still drops every Linux capability and \
2911sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
2912verified either. Treat shell subprocess isolation as experimental on this host.";
2913
2914const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
2915platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
2916enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
2917permanent on this platform.";
2918
2919/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
2920///
2921///   - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
2922///     is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
2923///   - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
2924///     (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
2925///   - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
2926///     sandboxing primitive at all on this platform, and this is permanent. Also still calls
2927///     `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
2928///     nothing is kernel-enforced.
2929///
2930/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
2931/// assert what each tier warns without capturing stderr.
2932pub(crate) fn tier_warning(
2933    tier: EnforcementTier,
2934    shell_allow_is_empty: bool,
2935) -> Option<(&'static str, &'static str)> {
2936    if shell_allow_is_empty {
2937        return None;
2938    }
2939    match tier {
2940        EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
2941        EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
2942        EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
2943        EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
2944    }
2945}
2946
2947const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn native subprocesses, but \
2948this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
2949tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
2950capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
2951does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
2952This is permanent on this platform, not a placeholder for a future slice.";
2953
2954/// Pure decision for the aggregate-bounding warning, split out of
2955/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
2956/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
2957///
2958/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
2959/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
2960/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
2961/// there is no running session left to warn about.
2962pub(crate) fn aggregate_bounding_warning(
2963    is_linux: bool,
2964    requires_bounding: bool,
2965    has_scope: bool,
2966) -> Option<(&'static str, &'static str)> {
2967    if is_linux || !requires_bounding || has_scope {
2968        return None;
2969    }
2970    Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
2971}
2972
2973/// Fires at every launch, not just once.
2974pub(crate) fn warn_for_missing_aggregate_bounding(
2975    workdir: &crate::workdir_fs::WorkdirRoot,
2976    requires_bounding: bool,
2977    has_scope: bool,
2978) {
2979    let is_linux = cfg!(target_os = "linux");
2980    if let Some((code, message)) =
2981        aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
2982    {
2983        let link = security_warning_link(code);
2984        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2985        crate::agent::append_bootstrap_log(
2986            workdir,
2987            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2988        );
2989    }
2990}
2991
2992/// Fires at every launch, not just once.
2993pub(crate) fn warn_for_enforcement_tier(
2994    tier: EnforcementTier,
2995    workdir: &crate::workdir_fs::WorkdirRoot,
2996    policy: &CapabilityPolicy,
2997) {
2998    if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
2999        let link = security_warning_link(code);
3000        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3001        crate::agent::append_bootstrap_log(
3002            workdir,
3003            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3004        );
3005    }
3006    if tier == EnforcementTier::EnvironmentOnly {
3007        crate::runtime::warn_if_bash_network_bypass(workdir, policy);
3008    }
3009}
3010
3011/// What `prepare_enforcement` hands back alongside the configured `Command`: on a kernel tier, the
3012/// read end of the child's diagnostic pipe.
3013///
3014/// The session's egress proxy is not here. It belongs to the session's
3015/// [`crate::network_namespace::SessionNetns`] and outlives every command, so a process a command
3016/// backgrounds keeps its allowlisted egress after the command returns.
3017#[derive(Debug)]
3018pub(crate) enum SupervisorHandle {
3019    Noop,
3020    #[cfg(target_os = "linux")]
3021    Linux {
3022        /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
3023        /// child's `pre_exec` closure. On a successful `execve` the write end closes
3024        /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
3025        /// setup failure the child writes the real error message here before returning `Err`,
3026        /// letting `execute_shell` fold that legible detail into the error it returns instead of
3027        /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
3028        diag_read: std::os::fd::OwnedFd,
3029    },
3030}
3031
3032impl SupervisorHandle {
3033    /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
3034    /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
3035    /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
3036    /// there is zero cost there.
3037    ///
3038    /// The caller must have already released every *parent-side* copy of the pipe's write end
3039    /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
3040    /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
3041    /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
3042    /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
3043    /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
3044    /// failing).
3045    pub(crate) fn read_diagnostic(&self) -> Option<String> {
3046        match self {
3047            SupervisorHandle::Noop => None,
3048            #[cfg(target_os = "linux")]
3049            SupervisorHandle::Linux { diag_read, .. } => {
3050                use std::os::fd::AsRawFd;
3051                linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3052            }
3053        }
3054    }
3055}
3056
3057// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
3058// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
3059// test builds; production `prepare_enforcement` has no bypass or injection point.
3060#[cfg(test)]
3061thread_local! {
3062    pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3063        const { std::cell::Cell::new(false) };
3064}
3065
3066#[cfg(test)]
3067fn forced_prepare_failure() -> Result<(), String> {
3068    if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3069        return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3070    }
3071    Ok(())
3072}
3073
3074// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
3075// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
3076// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
3077// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
3078// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
3079// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
3080// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
3081// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
3082#[cfg(all(test, target_os = "linux"))]
3083thread_local! {
3084    pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3085        const { std::cell::Cell::new(false) };
3086    pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3087        const { std::cell::Cell::new(false) };
3088    /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
3089    /// only fail on a host that can attempt it at all, and the thing under test — that a
3090    /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
3091    /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
3092    /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
3093    pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3094        const { std::cell::Cell::new(false) };
3095    /// Fails the `/tmp` mapping of a `MountNamespace` spawn at its first step, before any real
3096    /// namespace syscall, so a failed mapping's path is testable on any Linux host.
3097    pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3098        const { std::cell::Cell::new(false) };
3099}
3100
3101/// RAII arm for [`FORCE_TMP_MAP_FAILURE`].
3102#[cfg(all(test, target_os = "linux"))]
3103pub(crate) struct ForceTmpMapFailureGuard;
3104
3105#[cfg(all(test, target_os = "linux"))]
3106impl ForceTmpMapFailureGuard {
3107    pub(crate) fn new() -> Self {
3108        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3109        Self
3110    }
3111}
3112
3113#[cfg(all(test, target_os = "linux"))]
3114impl Drop for ForceTmpMapFailureGuard {
3115    fn drop(&mut self) {
3116        FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3117    }
3118}
3119
3120/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
3121/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
3122/// and `runtime`'s (which checks what the dispatch layer does with it).
3123#[cfg(all(test, target_os = "linux"))]
3124pub(crate) struct ForceSealedRootFailureGuard;
3125
3126#[cfg(all(test, target_os = "linux"))]
3127impl ForceSealedRootFailureGuard {
3128    pub(crate) fn new() -> Self {
3129        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3130        Self
3131    }
3132}
3133
3134#[cfg(all(test, target_os = "linux"))]
3135impl Drop for ForceSealedRootFailureGuard {
3136    fn drop(&mut self) {
3137        FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3138    }
3139}
3140
3141/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
3142/// half of the sealed mechanism still runs for real against it (the composed root is planned and
3143/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
3144/// which is what makes these tests run identically on a sealed-capable host and on one that could
3145/// never reach `KernelSealed` at all.
3146#[cfg(all(test, target_os = "linux"))]
3147pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3148    // Kept past the test: the enforcement may be cloned into a thread that outlives the value.
3149    let base = tempfile::tempdir().unwrap().keep();
3150    ShellEnforcement {
3151        tier: EnforcementTier::KernelSealed,
3152        ..ShellEnforcement::environment_only().with_test_session_dirs()
3153    }
3154    .with_runtime_dir(test_runtime_dir(&base), None)
3155}
3156
3157/// `runtime/`, `sealed-etc/`, `harness/` and a records directory holding a temporary folder and
3158/// a home folder, created under `base`, held as a session's runtime directory roots.
3159#[cfg(test)]
3160pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3161    let open = |sub: &str| {
3162        let path = base.join(sub);
3163        std::fs::create_dir_all(&path).unwrap();
3164        crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3165    };
3166    crate::runtime_dir::RuntimeDirRoots::new(
3167        open(crate::runtime_dir::RUNTIME_SUBDIR),
3168        open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3169        open(crate::runtime_dir::HARNESS_SUBDIR),
3170    )
3171    .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3172    .with_temp(open(&format!(
3173        "{}/{}",
3174        crate::records_dir::RECORDS_DIR_NAME,
3175        crate::temp_dir::SESSION_TEMP_DIR_NAME
3176    )))
3177    .with_home(open(&format!(
3178        "{}/{}",
3179        crate::records_dir::RECORDS_DIR_NAME,
3180        crate::records_dir::HOME_SUBDIR
3181    )))
3182}
3183
3184/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
3185/// exists — to `command`, so they apply to the spawned process and everything it forks.
3186///
3187/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
3188/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
3189/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. So this is
3190/// the whole of `prepare_enforcement` on `EnvironmentOnly` and off Linux, through which every
3191/// shell command, plan shell step and native tool is spawned.
3192///
3193/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
3194/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
3195/// allocation, no locking, no path resolution.
3196#[cfg(unix)]
3197pub(crate) fn attach_process_limits(
3198    command: &mut std::process::Command,
3199    enforcement: &ShellEnforcement,
3200) {
3201    use std::os::unix::process::CommandExt;
3202
3203    let limits = enforcement.resource_limits;
3204    let nproc = enforcement.nproc_bound();
3205    let cgroup_scope = enforcement.cgroup_scope.clone();
3206
3207    // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
3208    // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
3209    // scope exists, a single `write` to a descriptor opened by the parent before the fork —
3210    // no allocation, no locks, no path lookups.
3211    #[allow(unsafe_code)]
3212    unsafe {
3213        command.pre_exec(move || {
3214            crate::resources::apply_hard_rlimits(&limits, nproc)?;
3215            if let Some(scope) = cgroup_scope.as_ref() {
3216                scope.join_current_process()?;
3217            }
3218            Ok(())
3219        });
3220    }
3221}
3222
3223/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
3224/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
3225///
3226/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles a
3227/// spawn installs *before* `pre_exec` runs exist so the exec'd program inherits them across its
3228/// own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
3229/// stdout/stderr, and `dispatch_native_tool` additionally writes its `ToolInput` JSON to the
3230/// subprocess's stdin and parses its `ToolResult` JSON back off stdout. Marking 0/1/2
3231/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
3232/// invocation, and there is nothing meaningful to reopen them onto: the pipes themselves are the
3233/// intended destination. The gap this constant closes is everything *above*
3234/// stdio, where inheritance was never intended and was only ever prevented by the empirical
3235/// accident of nothing else happening to be open at spawn time.
3236///
3237/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
3238/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
3239/// module carry.
3240#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3241pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3242
3243/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
3244/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
3245/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
3246/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
3247/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
3248/// no kernel sandbox at all.
3249#[cfg(not(target_os = "linux"))]
3250pub(crate) fn prepare_enforcement(
3251    command: &mut std::process::Command,
3252    enforcement: &ShellEnforcement,
3253    _accessible_workdir: &Path,
3254) -> Result<SupervisorHandle, String> {
3255    #[cfg(test)]
3256    forced_prepare_failure()?;
3257
3258    debug_assert_eq!(
3259        enforcement.tier,
3260        EnforcementTier::EnvironmentOnly,
3261        "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly"
3262    );
3263    attach_process_limits(command, enforcement);
3264    Ok(SupervisorHandle::Noop)
3265}
3266
3267/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
3268/// submodule for the mechanics: the session's network namespace is established in the parent,
3269/// and the `pre_exec` closure sets `RLIMIT_NPROC` or joins the cgroup scope, then joins the
3270/// namespace, then maps `/tmp` or installs the composed root, then seccomp and Landlock, and
3271/// applies the per-process rlimits last.
3272#[cfg(target_os = "linux")]
3273#[allow(unsafe_code)]
3274pub(crate) fn prepare_enforcement(
3275    command: &mut std::process::Command,
3276    enforcement: &ShellEnforcement,
3277    accessible_workdir: &Path,
3278) -> Result<SupervisorHandle, String> {
3279    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3280    use std::os::unix::process::CommandExt;
3281
3282    #[cfg(test)]
3283    forced_prepare_failure()?;
3284
3285    if enforcement.tier == EnforcementTier::EnvironmentOnly {
3286        // No kernel sandbox to install, but the POSIX rlimit ceilings still apply — this tier is
3287        // only reachable on Linux from tests, and even there it must not read as "unbounded".
3288        attach_process_limits(command, enforcement);
3289        return Ok(SupervisorHandle::Noop);
3290    }
3291
3292    // The session's temporary folder, reopened from its root's held descriptor: its Landlock
3293    // rule's fd on both Landlock tiers, and the identity the sealed bind and the mount-namespace
3294    // bind both verify, so a link swapped onto its path redirects neither.
3295    let temp_dir_fd = enforcement
3296        .runtime_dir
3297        .as_ref()
3298        .and_then(|roots| roots.temp.as_ref())
3299        .map(|temp| temp.path_fd().map(Arc::new))
3300        .transpose()
3301        .map_err(|error| {
3302            format!("sandbox: failed to open the session's temporary folder: {error}")
3303        })?;
3304    // The session's home folder, on exactly the temporary folder's terms: its Landlock rule's fd
3305    // on both Landlock tiers and the identity its sealed bind verifies.
3306    let home_dir_fd = enforcement
3307        .home_root()
3308        .map(|home| home.path_fd().map(Arc::new))
3309        .transpose()
3310        .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3311
3312    // The descriptors a rule and a bind share on `KernelSealed`: the temporary folder and the
3313    // runtime and records directories, each taken once, with no link followed, from a held
3314    // descriptor. The Landlock rule is taken on the descriptor, and the bind is refused unless
3315    // the directory the child reaches is the one it names, so a link swapped in afterwards
3316    // changes neither.
3317    let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3318        Some(SealedSourceFds::open(
3319            enforcement,
3320            temp_dir_fd.as_ref(),
3321            home_dir_fd.as_ref(),
3322        )?)
3323    } else {
3324        None
3325    };
3326    // On `KernelFull` the runtime directory needs the Landlock rule alone.
3327    let runtime_dir_fd = match &sealed_sources {
3328        Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3329        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3330            .runtime_dir
3331            .as_ref()
3332            .map(|roots| roots.runtime.path_fd().map(Arc::new))
3333            .transpose()
3334            .map_err(|error| {
3335                format!(
3336                    "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3337                )
3338            })?,
3339        None => None,
3340    };
3341    // The records directory takes the runtime directory's rule on both Landlock tiers, from the
3342    // same descriptor its sealed bind verifies.
3343    let records_dir_fd = match &sealed_sources {
3344        Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3345        None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3346            .runtime_dir
3347            .as_ref()
3348            .and_then(|roots| roots.records.as_ref())
3349            .map(|records| records.path_fd().map(Arc::new))
3350            .transpose()
3351            .map_err(|error| {
3352                format!(
3353                    "sandbox: failed to open the records directory for Landlock scoping: {error}"
3354                )
3355            })?,
3356        None => None,
3357    };
3358
3359    // The private `/tmp` of a `MountNamespace` spawn, every path and buffer of it built here.
3360    let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3361        (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3362            build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3363        ),
3364        _ => None,
3365    };
3366
3367    // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
3368    // is a normal synchronous error here — it names the path and returns before any subprocess is
3369    // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
3370    // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
3371    // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
3372    // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
3373    let landlock_fds = if matches!(
3374        enforcement.tier,
3375        EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3376    ) {
3377        Some(linux_enforce::open_landlock_fds(
3378            linux_enforce::LandlockFdRequest {
3379                accessible_workdir,
3380                landlock_grants: &enforcement.landlock_grants,
3381                device_grants: &landlock_device_grants(enforcement.tier),
3382                workdir_exec: enforcement.workdir_exec,
3383                abi: enforcement.landlock_abi,
3384                sealed: sealed_sources.as_ref(),
3385                temp_dir_fd,
3386                home_dir_fd,
3387                runtime_dir_fd,
3388                records_dir_fd,
3389            },
3390        )?)
3391    } else {
3392        None
3393    };
3394
3395    // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
3396    // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
3397    // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
3398    // thread that no longer exists — happens now instead, synchronously, where a failure names
3399    // itself. See `crate::sealed` for the plan/execute split.
3400    let sealed_spec = match &sealed_sources {
3401        Some(sources) => Some(build_sealed_root(
3402            accessible_workdir,
3403            sources,
3404            &enforcement.sealed_bind_dirs,
3405            &enforcement.staged_runtime_dirs,
3406        )?),
3407        None => None,
3408    };
3409
3410    // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
3411    // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
3412    // nothing written (the parent's read then returns immediate EOF — zero cost on the success
3413    // path), while any `pre_exec` setup failure writes its real message here before the closure
3414    // returns `Err`.
3415    let mut diag_fds = [0i32; 2];
3416    // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
3417    // other precondition applies.
3418    let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3419    if rc != 0 {
3420        return Err(format!(
3421            "sandbox: pipe2() for enforcement diagnostics failed: {}",
3422            std::io::Error::last_os_error()
3423        ));
3424    }
3425    // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
3426    // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
3427    let (diag_read, diag_write) = unsafe {
3428        (
3429            OwnedFd::from_raw_fd(diag_fds[0]),
3430            OwnedFd::from_raw_fd(diag_fds[1]),
3431        )
3432    };
3433
3434    // The session's network namespace, created by the first command that needs it and joined by
3435    // every one after. Established here, in the parent and before the fork, so the egress proxy
3436    // is already serving the namespace's listeners when the command's first connection arrives,
3437    // and so a namespace that cannot be created fails this spawn with a legible error instead of
3438    // running the command anywhere else. The join handle moves into the closure, which keeps both
3439    // descriptors open until the `Command` is dropped.
3440    let netns = enforcement.session_netns.establish(
3441        &enforcement.egress_tcp_ports,
3442        crate::egress_proxy::EgressPolicy::new(
3443            enforcement.network_allow_rules.clone(),
3444            enforcement.network_allow_ips.clone(),
3445        ),
3446    )?;
3447
3448    let tier = enforcement.tier;
3449    // Copied out before the `move` closure below takes ownership of everything it touches, the
3450    // same clone-before-move shape `tier` and the Landlock fds already use.
3451    let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3452    // Same clone-before-move shape. `HostResourceLimits` and `NprocBound` are `Copy`, and the
3453    // `RLIMIT_NPROC` decision is made here because its baseline is a `/proc` walk; the cgroup scope
3454    // is an `Arc` whose `cgroup.procs` descriptor was opened in the parent at scope-creation time,
3455    // so the child performs no path lookup to join it.
3456    let resource_limits = enforcement.resource_limits;
3457    let nproc = enforcement.nproc_bound();
3458    let cgroup_scope = enforcement.cgroup_scope.clone();
3459
3460    // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
3461    // narrow pre_exec window where only async-signal-safe operations are permitted. In order, it
3462    // performs: without a cgroup scope, the `RLIMIT_NPROC` `getrlimit`/`setrlimit` pair of
3463    // `resources::apply_nproc_bound`; with one, one `write` to the `cgroup.procs` descriptor the
3464    // parent opened; two `setns` calls into the session's namespaces; on a `MountNamespace` spawn
3465    // the `unshare`/`mount`/`open`/`mkdirat`/`chdir` sequence of `temp_dir::map_private_tmp`, and
3466    // on `KernelSealed` the `unshare`/`open`/`write`/`mount`/`pivot_root` sequence of
3467    // `sealed::construct_composed_root`, both over paths and buffers built here; the
3468    // `close_range` fd-hygiene call; the capability-dropping `prctl`/`capset` sequence
3469    // (bounding-set drop, capset clear, and the explicit `no_new_privs` `prctl`) plus one
3470    // allocation-free `open`/`read`/`close` of `/proc/sys/kernel/cap_last_cap`; libseccomp filter
3471    // construction/load (kernel syscalls); on `KernelFull`/`KernelSealed` Landlock ruleset
3472    // construction/`restrict_self` against already-open fds (also kernel syscalls); and last, the
3473    // `getrlimit`/`setrlimit` pairs of `resources::apply_per_process_rlimits`. No `canonicalize()`, no
3474    // path lookup beyond those prepared paths and `cap_last_cap`, no locks beyond what those
3475    // syscalls need. On any failure it writes the real error message to the CLOEXEC diagnostic
3476    // pipe (best-effort, bounded, raw `write` loop) before returning `Err`. `diag_write`, the
3477    // namespace join handle and the Landlock fds are moved in, and are `CLOEXEC`, so none of them
3478    // reaches the exec'd program.
3479    unsafe {
3480        command.pre_exec(move || {
3481            // `RLIMIT_NPROC` first: the composed root's `unshare(CLONE_NEWUSER)` below fixes the
3482            // session namespace's task bound at this child's limit as of that call, and a later
3483            // `setrlimit` would bound only the new namespace's own tasks. It is set only without
3484            // a cgroup scope (`NprocBound::Headroom`) and creates no descriptor.
3485            if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
3486                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3487                return Err(error);
3488            }
3489            if let Some(scope) = cgroup_scope.as_ref() {
3490                if let Err(error) = scope.join_current_process() {
3491                    linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3492                    return Err(error);
3493                }
3494            }
3495
3496            let (user_ns_fd, net_ns_fd) = netns.raw_fds();
3497            if let Err(error) = linux_enforce::child_install_enforcement(
3498                tier,
3499                user_ns_fd,
3500                net_ns_fd,
3501                tmp_map_spec.as_ref(),
3502                sealed_spec.as_ref(),
3503                landlock_fds.as_ref(),
3504                unix_sockets_allowed,
3505            ) {
3506                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3507                return Err(error);
3508            }
3509
3510            // The per-process rlimits come last, as the final call before `execve`. They cannot
3511            // come first: every descriptor the steps above create (the `/tmp` mapping's `O_PATH`
3512            // fds, the composed root's `/proc/self/*_map` writes and `MkFile` steps,
3513            // `cap_last_cap`, the Landlock ruleset) must be numbered below `RLIMIT_NOFILE`, and
3514            // this child holds every descriptor `mur` had open at the fork, so the lowest free
3515            // number can be above a modest `max_open_files`. Last still bounds the exec'd program
3516            // from its first instruction: a step above that fails returns `Err`, which aborts the
3517            // exec, so no capsule code runs in this child; a failing `setrlimit` aborts it the
3518            // same way; and `prlimit64`/`getrlimit`/`setrlimit` are in
3519            // `SECCOMP_SYSCALL_ALLOWLIST`, and lowering a limit needs no capability, so neither
3520            // the filter nor the capability drop stops them.
3521            if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
3522                linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3523                return Err(error);
3524            }
3525            Ok(())
3526        });
3527    }
3528
3529    Ok(SupervisorHandle::Linux { diag_read })
3530}
3531
3532/// Which fixed device set Landlock grants, keyed on the tier.
3533///
3534/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
3535/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
3536/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
3537/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
3538/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
3539///
3540/// The parent opens these paths on the *host*, before the namespace exists; because the composed
3541/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
3542/// are the inodes the capsule reaches.
3543#[cfg(target_os = "linux")]
3544fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
3545    match tier {
3546        EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
3547            .iter()
3548            .map(|device| CapsuleDeviceGrant {
3549                path: device.path,
3550                writable: device.writable,
3551            })
3552            .collect(),
3553        _ => CAPSULE_DEVICE_GRANTS.to_vec(),
3554    }
3555}
3556
3557/// The `/tmp` mapping of one `MountNamespace` spawn: the folder held by `folder_fd` bound over
3558/// canonical `/tmp`, and every path beneath the host's `/tmp` the command still needs re-exposed
3559/// at its own path.
3560///
3561/// Those paths are the accessible workdir and the session's home folder, bound read-write, and the runtime and
3562/// records directories and every Landlock grant path, which already carries the `shell.allow`
3563/// closure, the interpreter and staged runtimes and a native tool's entrypoint closure, bound
3564/// read-only. Landlock adds the folder's rights to everything bound beneath it, which are the
3565/// workdir's own; a read-only bind is what keeps them from writing the rest. See
3566/// [`crate::temp_dir::reexposed_paths`].
3567#[cfg(target_os = "linux")]
3568fn build_tmp_map_spec(
3569    enforcement: &ShellEnforcement,
3570    folder_fd: &std::os::fd::OwnedFd,
3571    accessible_workdir: &Path,
3572) -> Result<crate::temp_dir::TmpMapSpec, String> {
3573    let folder = enforcement.temp_dir_path().ok_or_else(|| {
3574        format!(
3575            "{} this enforcement holds none",
3576            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3577        )
3578    })?;
3579    let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
3580        format!(
3581            "{} fstat of the temporary folder failed: {errno}",
3582            crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3583        )
3584    })?;
3585    let canonical_tmp = crate::temp_dir::canonical_tmp();
3586    let read_only = [
3587        enforcement.runtime_dir_path(),
3588        enforcement.records_dir_path(),
3589    ]
3590    .into_iter()
3591    .flatten()
3592    .chain(
3593        enforcement
3594            .landlock_grants
3595            .iter()
3596            .map(|grant| grant.path.as_path()),
3597    );
3598    let reexposed = crate::temp_dir::reexposed_paths(
3599        &canonical_tmp,
3600        std::iter::once(accessible_workdir).chain(enforcement.home_dir_path()),
3601        read_only,
3602    );
3603    crate::temp_dir::TmpMapSpec::new(
3604        folder,
3605        identity,
3606        &canonical_tmp,
3607        &reexposed,
3608        accessible_workdir,
3609    )
3610}
3611
3612/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
3613/// of the host's into the session's `sealed-etc/` directory, returning their host paths in
3614/// [`crate::sealed::SEALED_ETC_PATHS`] order.
3615///
3616/// `sealed-etc/` sits beside the runtime directory, outside every workdir, and no capsule command
3617/// is granted it. The account the files name is the home folder's owner, the identity the
3618/// capsule's subprocesses run as, with the session's home folder as its home directory.
3619///
3620/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
3621/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
3622/// nothing the parent could have done for it.
3623///
3624/// The rewrite is in place ([`crate::workdir_fs::WorkdirRoot::overwrite_in_place`] truncates; it
3625/// does not replace): Landlock grants the file by inode and the composed root binds it by path, so
3626/// replacing the file would leave `/etc/passwd` bound but denied (`EACCES`).
3627#[cfg(target_os = "linux")]
3628fn ensure_sealed_identity_files(
3629    sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
3630    home_root: &crate::workdir_fs::WorkdirRoot,
3631) -> Result<Vec<PathBuf>, String> {
3632    // The home folder was created by this process, so its owner is the identity the capsule's
3633    // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
3634    let (uid, gid) = home_root.owner().map_err(|error| {
3635        format!(
3636            "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
3637        )
3638    })?;
3639    // The same string `build_shell_env` puts in `$HOME`: the home folder's own absolute path,
3640    // which the composed root binds at that same path.
3641    let home = home_root.path().to_string_lossy().into_owned();
3642    let identity = crate::sealed::SealedAccountIdentity {
3643        uid,
3644        gid,
3645        home: &home,
3646    };
3647
3648    let mut written = Vec::new();
3649    for entry in crate::sealed::SEALED_ETC_PATHS {
3650        let Some(file) = entry.synthetic else {
3651            continue;
3652        };
3653        sealed_etc_root
3654            .overwrite_in_place(
3655                file.file_name(),
3656                file.render(&identity)?.as_bytes(),
3657                crate::workdir_fs::DEFAULT_FILE_MODE,
3658            )
3659            .map_err(|error| {
3660                format!(
3661                    "sealed: failed to write the synthetic {}: {error}",
3662                    entry.path
3663                )
3664            })?;
3665        written.push(crate::sealed::synthetic_etc_source(
3666            sealed_etc_root.path(),
3667            file,
3668        ));
3669    }
3670    Ok(written)
3671}
3672
3673/// The descriptors a composed root's runtime-provided binds are verified against and its Landlock
3674/// rules are taken on, opened in the parent before the fork.
3675#[cfg(target_os = "linux")]
3676pub(crate) struct SealedSourceFds {
3677    /// The session's temporary folder, reopened from its root's held descriptor: the `/tmp`
3678    /// rule's fd.
3679    tmp_store: Arc<std::os::fd::OwnedFd>,
3680    /// The folder's host path, which the child opens and verifies against [`Self::tmp_store`].
3681    tmp_store_path: PathBuf,
3682    /// The session's home folder, reopened from its root's held descriptor, and its absolute
3683    /// path: its rule's fd and its read-write bind at that same path.
3684    home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
3685    /// The runtime directory, reopened from its root's held descriptor: its rule's fd.
3686    runtime_dir: Arc<std::os::fd::OwnedFd>,
3687    /// The runtime directory's absolute path, which the bind reproduces inside the root and the
3688    /// child verifies against [`Self::runtime_dir`].
3689    runtime_dir_path: PathBuf,
3690    /// The records directory, reopened from its root's held descriptor, and its absolute path:
3691    /// its rule's fd and its read-only bind, on the runtime directory's terms. `None` for an
3692    /// enforcement held outside a session.
3693    records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3694    /// `sealed-etc/`'s host path.
3695    etc_dir: PathBuf,
3696    /// The synthetic `/etc` files, in [`crate::sealed::SEALED_ETC_PATHS`] order.
3697    identity_files: Vec<PathBuf>,
3698}
3699
3700#[cfg(target_os = "linux")]
3701impl SealedSourceFds {
3702    /// Writes the synthetic `/etc` files and takes every descriptor, with `temp_dir_fd` and
3703    /// `home_dir_fd` the descriptors `prepare_enforcement` took on the session's temporary and
3704    /// home folders. A sealed root cannot be composed for an enforcement that holds no runtime
3705    /// directory, no temporary folder or no home folder.
3706    fn open(
3707        enforcement: &ShellEnforcement,
3708        temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3709        home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3710    ) -> Result<Self, String> {
3711        let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
3712            "sealed: a composed root needs the session's runtime directory, and this \
3713             enforcement holds none"
3714                .to_string()
3715        })?;
3716        let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
3717            return Err(
3718                "sealed: a composed root binds the session's temporary folder at /tmp, \
3719                        and this enforcement holds none"
3720                    .to_string(),
3721            );
3722        };
3723        let tmp_store = Arc::clone(tmp_store);
3724        let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
3725            return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
3726        };
3727        let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
3728        let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
3729        let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
3730            format!("sealed: failed to open the runtime directory for its bind: {error}")
3731        })?);
3732        let records_dir = roots
3733            .records
3734            .as_ref()
3735            .map(|records| {
3736                records
3737                    .path_fd()
3738                    .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
3739                    .map_err(|error| {
3740                        format!(
3741                            "sealed: failed to open the records directory for its bind: {error}"
3742                        )
3743                    })
3744            })
3745            .transpose()?;
3746        Ok(Self {
3747            tmp_store,
3748            tmp_store_path: temp.path().to_path_buf(),
3749            home_dir,
3750            runtime_dir,
3751            runtime_dir_path: roots.runtime.path().to_path_buf(),
3752            records_dir,
3753            etc_dir: roots.sealed_etc.path().to_path_buf(),
3754            identity_files,
3755        })
3756    }
3757
3758    /// `(st_dev, st_ino)` of `fd`, which a bind's source must still have in the child.
3759    fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
3760        crate::workdir_fs::fd_identity(fd)
3761            .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
3762    }
3763}
3764
3765/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
3766/// host layout, and lower the plan into the C-string form the forked child executes.
3767///
3768/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
3769/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
3770/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
3771/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
3772/// declared-vs-achieved mismatch.
3773///
3774/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
3775/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
3776/// error would return `Err(String)`, which converts to the ordinary, retryable
3777/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
3778/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
3779/// classification for a capsule that did not get a runtime tree it declared.
3780#[cfg(target_os = "linux")]
3781fn build_sealed_root(
3782    accessible_workdir: &Path,
3783    sources: &SealedSourceFds,
3784    extra_read_only: &[PathBuf],
3785    staged_runtime_read_only: &[PathBuf],
3786) -> Result<crate::sealed::SealedRootSpec, String> {
3787    use crate::sealed;
3788
3789    // The workdir must be absolute for its path to mean the same thing inside the composed root,
3790    // and `launch_session` always creates it as one — but the composed root's whole design rests
3791    // on it, so it is checked rather than assumed.
3792    if !accessible_workdir.is_absolute() {
3793        return Err(format!(
3794            "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
3795             its own absolute path",
3796            accessible_workdir.display()
3797        ));
3798    }
3799
3800    let base = sealed::choose_root_base(
3801        accessible_workdir,
3802        sealed::SEALED_ROOT_BASE_CANDIDATES,
3803        |path| path.is_dir(),
3804    )
3805    .ok_or_else(|| {
3806        format!(
3807            "sealed: no usable base directory for the composed root; none of {:?} exists outside \
3808             the session workdir's own path",
3809            sealed::SEALED_ROOT_BASE_CANDIDATES
3810        )
3811    })?;
3812
3813    let plan = sealed::plan_composed_root(
3814        accessible_workdir,
3815        &sealed::SealedBindSources {
3816            tmp_store: (
3817                &sources.tmp_store_path,
3818                SealedSourceFds::identity_of(&sources.tmp_store)?,
3819            ),
3820            etc_dir: &sources.etc_dir,
3821            runtime_dir: Some((
3822                &sources.runtime_dir_path,
3823                SealedSourceFds::identity_of(&sources.runtime_dir)?,
3824            )),
3825            records_dir: sources
3826                .records_dir
3827                .as_ref()
3828                .map(|(fd, path)| {
3829                    SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
3830                })
3831                .transpose()?,
3832            home_dir: Some((
3833                &sources.home_dir.1,
3834                SealedSourceFds::identity_of(&sources.home_dir.0)?,
3835            )),
3836        },
3837        &base,
3838        extra_read_only,
3839        staged_runtime_read_only,
3840        &sealed::RealHostLayout,
3841    );
3842    sealed::build_sealed_root_spec(&plan)
3843}
3844
3845/// Linux-only mechanics: the `pre_exec` enforcement sequence, seccomp filter construction and
3846/// Landlock application.
3847///
3848/// Nothing here uses `libseccomp`'s notify facility. `connect`/`sendto` are enforced by the
3849/// network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which decide
3850/// from kernel-held state. A `Notify` rule would reintroduce a `/proc/<pid>/mem` read on the hot
3851/// path, where the value read can change between the decision and the kernel's own dereference.
3852#[cfg_attr(target_os = "linux", allow(unsafe_code))]
3853#[cfg(target_os = "linux")]
3854mod linux_enforce {
3855    use std::io;
3856    use std::os::fd::{AsFd, OwnedFd, RawFd};
3857    use std::path::Path;
3858
3859    use landlock::{make_bitflags, AccessFs, BitFlags};
3860
3861    use super::{EnforcementTier, LandlockGrant};
3862
3863    /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
3864    /// naming the failed step is far shorter than this; the bound just keeps the best-effort
3865    /// `write`/`read` loops trivially terminating.
3866    const MAX_DIAG_LEN: usize = 1024;
3867
3868    /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
3869    /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
3870    /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
3871    /// never a path to re-open there.
3872    pub(super) struct OpenLandlockGrant {
3873        fd: OwnedFd,
3874        list_dir: bool,
3875        executable: bool,
3876    }
3877
3878    /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
3879    /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
3880    /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
3881    /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
3882    /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
3883    /// their bits must not be confusable.
3884    pub(super) struct OpenDeviceGrant {
3885        fd: OwnedFd,
3886        writable: bool,
3887    }
3888
3889    /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
3890    /// workdir's fd and the session's temporary folder's (both scoped by
3891    /// [`workdir_access_rights`]), each successfully-opened grant fd, and each
3892    /// successfully-opened fixed-device fd.
3893    /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
3894    /// against these fds without performing a single `open()`.
3895    pub(super) struct LandlockChildFds {
3896        workdir_fd: OwnedFd,
3897        /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
3898        /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
3899        /// but its argument — has the whole workdir rule decided for it before fork.
3900        workdir_exec: bool,
3901        /// The Landlock ABI the ruleset is built at, decided in the parent from the session's
3902        /// [`super::HostProbe`] (see [`super::ShellEnforcement::landlock_abi`]) so the child reads
3903        /// no host state of its own. `None` builds at ABI v1, the narrowest set.
3904        abi: Option<u8>,
3905        /// The session's temporary folder, `tmp/` in its records directory, on both Landlock
3906        /// tiers whenever the enforcement holds one, mapped onto `/tmp` or not. `None` outside a
3907        /// session. On `KernelSealed` the same descriptor is the composed root's `/tmp` bind
3908        /// source.
3909        ///
3910        /// It needs a rule of its own on every mapped tier: Landlock resolves a path by walking
3911        /// dentries up to each mount root, so an access to `/tmp/x` is matched against the folder
3912        /// (the bind's root) and then against the mountpoint `/tmp` — never against the records
3913        /// directory or the workdir. It carries the workdir's own right-set, `Execute` axis
3914        /// included, so `workdir_exec: false` forbids running anything written there.
3915        temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
3916        /// The session's home folder, `home/` in its records directory, on exactly
3917        /// [`Self::temp_dir_fd`]'s terms: the workdir's rights, on both Landlock tiers, whenever the
3918        /// enforcement holds one. On `KernelSealed` the same descriptor is the read-write bind's
3919        /// source.
3920        home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
3921        /// The synthetic `/etc/passwd`/`/etc/group` in the session's `sealed-etc/`, opened on
3922        /// `KernelSealed` and empty on every other tier.
3923        ///
3924        /// Exactly the [`Self::temp_dir_fd`] problem, one directory over. The grant list already
3925        /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
3926        /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
3927        /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
3928        /// at the bound file's own inode, so without these fds both account databases would be
3929        /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
3930        ///
3931        /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
3932        /// nothing inside a capsule has any business editing an account database.
3933        sealed_identity_fds: Vec<OwnedFd>,
3934        /// The session's runtime directory, reopened from its root's held descriptor: `ReadFile`
3935        /// and `ReadDir` and nothing else, so every command reads `MURMUR.md`, `task.md`, plans
3936        /// and installed tools and changes none of them. `None` outside a session. On
3937        /// `KernelSealed` the same descriptor is the read-only bind's source.
3938        runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
3939        /// The session's records directory, reopened from its root's held descriptor, granted on
3940        /// [`Self::runtime_dir_fd`]'s terms: every command reads the trace and logs and changes
3941        /// none of them. `None` outside a session. On `KernelSealed` the same descriptor is the
3942        /// read-only bind's source.
3943        records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
3944        grants: Vec<OpenLandlockGrant>,
3945        devices: Vec<OpenDeviceGrant>,
3946    }
3947
3948    impl LandlockChildFds {
3949        /// The temporary folder's fd, for the tier-gate test. Keeps the struct's fields private
3950        /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
3951        #[cfg(test)]
3952        pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
3953            self.temp_dir_fd.as_deref()
3954        }
3955
3956        /// The home folder's fd, for the tier-gate test, on the same terms.
3957        #[cfg(test)]
3958        pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
3959            self.home_dir_fd.as_deref()
3960        }
3961
3962        /// The runtime directory's fd, for the tier-gate test, on the same terms.
3963        #[cfg(test)]
3964        pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
3965            self.runtime_dir_fd.as_deref()
3966        }
3967
3968        /// The records directory's fd, for the tier-gate test, on the same terms.
3969        #[cfg(test)]
3970        pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
3971            self.records_dir_fd.as_deref()
3972        }
3973
3974        /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
3975        /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
3976        #[cfg(test)]
3977        pub(super) fn sealed_identity_fd_count(&self) -> usize {
3978            self.sealed_identity_fds.len()
3979        }
3980
3981        /// How many grant fds were opened, for the pinned-grant test. A count, for the same reason.
3982        #[cfg(test)]
3983        pub(super) fn grant_count(&self) -> usize {
3984            self.grants.len()
3985        }
3986    }
3987
3988    /// The Landlock rights granted on the capsule **workdir's own** `PathBeneath` rule,
3989    /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
3990    /// declared `capabilities.filesystem.workdir_exec: true`.
3991    ///
3992    /// Spelled out variant by variant rather than `AccessFs::from_all` for two reasons: `from_all`
3993    /// hides what is actually handed to the capsule, and four rights are withheld from this
3994    /// constant. It is the set for [`super::LANDLOCK_TARGET_ABI`]; `apply_landlock_scope` masks it
3995    /// to the ABI the ruleset is built at, so on an older kernel the rights that ABI does not know
3996    /// simply drop out.
3997    ///
3998    /// Two rights are granted from an ABI later than v1:
3999    ///
4000    ///   - `Refer` (ABI v2, Linux 5.19) — moving and hard-linking a file between two folders.
4001    ///     Landlock refuses every cross-folder `rename(2)`/`link(2)` with `EXDEV` unless the ruleset
4002    ///     handles `Refer` and both folders grant it, so without it every tool that renames a
4003    ///     finished file into place from a sibling folder fails in the workdir. It reaches nothing
4004    ///     outside: a move or link needs `Refer` on both the source and the destination folder, no
4005    ///     rule outside the workdir and the session's temporary folder carries it, and Landlock
4006    ///     also refuses any move that would leave the file with more rights at its destination
4007    ///     than its source.
4008    ///   - `Truncate` (ABI v3, Linux 6.2) — `truncate(2)`, `ftruncate(2)` and `open(O_TRUNC)`.
4009    ///     Below v3 Landlock does not mediate truncation at all, so a capsule could zero any host
4010    ///     file its uid can write. Handling it denies that everywhere except the rules that grant
4011    ///     it, which are exactly the ones already carrying `WriteFile`: this one and the writable
4012    ///     devices.
4013    ///
4014    ///   - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
4015    ///     makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
4016    ///     on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
4017    ///     cannot run under any name — including an allowlisted basename, the exact bypass a prior
4018    ///     release shipped. The cost: a binary the capsule legitimately compiled in its workdir
4019    ///     cannot run either, which is what `workdir_exec: true` buys back.
4020    ///   - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
4021    ///     whole scope. A capsule running as root could `mknod` a node for the host's own disk
4022    ///     (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
4023    ///     permits, because the new inode lives beneath a granted path — then `open()` it and
4024    ///     read the raw host filesystem underneath every other restriction. No known workload
4025    ///     creates device nodes in its working tree.
4026    ///   - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
4027    ///     filesystem object with a kernel-side identity beyond a regular file), but unlike the
4028    ///     two device rights this one is a genuine open question: some build tooling and some
4029    ///     language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
4030    ///     that breaks those is not a fix. The manual acceptance procedure in
4031    ///     `docs/content/reference/workdir-device-node-manual-verification.md` carries an
4032    ///     explicit unix-socket scenario for exactly this; if the team's
4033    ///     real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
4034    ///
4035    /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
4036    /// and a FIFO carries none of the raw-device risk above.
4037    ///
4038    /// Because `apply_landlock_scope`'s `handle_access` declares the *full* `from_all` set of the
4039    /// ABI it builds at, a right this rule withholds is not merely "not extra-granted" — it
4040    /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
4041    /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
4042    /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
4043    /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
4044    /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
4045    /// nowhere the capsule writes.
4046    pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4047        WriteFile
4048            | ReadFile
4049            | ReadDir
4050            | RemoveDir
4051            | RemoveFile
4052            | MakeDir
4053            | MakeReg
4054            | MakeFifo
4055            | MakeSym
4056            | Refer
4057            | Truncate
4058    });
4059
4060    /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
4061    /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
4062    ///
4063    /// A function rather than a second constant so the two cases cannot drift: adding a right to
4064    /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
4065    /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
4066    /// The whole point of the default is that the workdir grant is no longer name-based at all.
4067    pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4068        if workdir_exec {
4069            WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4070        } else {
4071            WORKDIR_ACCESS_RIGHTS_NO_EXEC
4072        }
4073    }
4074
4075    /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock.
4076    ///
4077    /// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the version and
4078    /// creates nothing — no ruleset fd and no domain — so unlike
4079    /// [`probe_landlock_full_access`] it is safe to call in-process. A kernel built without
4080    /// Landlock, or with it disabled at boot, fails it with `ENOSYS`/`EOPNOTSUPP`.
4081    pub(super) fn kernel_landlock_abi() -> Option<u8> {
4082        const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4083        // SAFETY: with a null attribute pointer, a zero size and only the VERSION flag set, the
4084        // kernel reads no memory and allocates no fd; the call has no side effect to undo.
4085        let version = unsafe {
4086            libc::syscall(
4087                libc::SYS_landlock_create_ruleset,
4088                std::ptr::null::<libc::c_void>(),
4089                0usize,
4090                LANDLOCK_CREATE_RULESET_VERSION,
4091            )
4092        };
4093        if version <= 0 {
4094            return None;
4095        }
4096        Some(u8::try_from(version).unwrap_or(u8::MAX))
4097    }
4098
4099    /// Probes real Landlock support by building a ruleset granting full access to `/` and
4100    /// calling `restrict_self()` **in a forked child**, so the host process is never placed
4101    /// into a Landlock domain by a mere capability check.
4102    ///
4103    /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
4104    /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
4105    /// mount family to the task and every descendant it will ever have, which is how Landlock
4106    /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
4107    /// before the sealed probe, and a session's probe is taken before any fork that would
4108    /// build a composed root, so restricting in-process poisons `sealed` on every host and every
4109    /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
4110    /// to suggest the runtime did it to itself.
4111    ///
4112    /// The ruleset is built in the parent because creating one and adding rules restricts
4113    /// nothing; only `restrict_self` applies it. That keeps everything the child does after
4114    /// the fork down to a `prctl` and one syscall.
4115    pub(super) fn probe_landlock_full_access() -> Option<bool> {
4116        use landlock::{
4117            Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4118            RulesetCreatedAttr, RulesetStatus, ABI,
4119        };
4120
4121        // The child reports which of the three outcomes it reached through its exit status;
4122        // nothing else crosses the fork.
4123        const PROBE_FULLY_ENFORCED: i32 = 0;
4124        const PROBE_PARTIAL: i32 = 1;
4125        const PROBE_FAILED: i32 = 2;
4126
4127        // ABI v1 on purpose: this decides whether a Landlock tier is available at all, which v1
4128        // answers on every Landlock kernel. The ABI a capsule's ruleset is built at is read
4129        // separately, by `kernel_landlock_abi`.
4130        let abi = ABI::V1;
4131        let access_all = AccessFs::from_all(abi);
4132
4133        let root_fd = PathFd::new("/").ok()?;
4134
4135        let ruleset = Ruleset::default()
4136            .set_compatibility(CompatLevel::BestEffort)
4137            .handle_access(access_all)
4138            .ok()?
4139            .create()
4140            .ok()?
4141            .add_rule(PathBeneath::new(root_fd, access_all))
4142            .ok()?;
4143
4144        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
4145        // confines itself to async-signal-safe work. Everything this child does is the
4146        // `restrict_self` syscall pair and `_exit`; the allocating part of building the
4147        // ruleset already happened above, in the parent.
4148        let pid = unsafe { libc::fork() };
4149        if pid < 0 {
4150            return None;
4151        }
4152        if pid == 0 {
4153            let code = match ruleset.restrict_self() {
4154                Ok(status) => {
4155                    if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4156                        PROBE_FULLY_ENFORCED
4157                    } else {
4158                        PROBE_PARTIAL
4159                    }
4160                }
4161                Err(_) => PROBE_FAILED,
4162            };
4163            // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4164            // which is what keeps the parent's state untouched.
4165            unsafe { libc::_exit(code) }
4166        }
4167
4168        let mut wait_status: libc::c_int = 0;
4169        // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
4170        let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4171        if waited < 0 || !libc::WIFEXITED(wait_status) {
4172            return None;
4173        }
4174        match libc::WEXITSTATUS(wait_status) {
4175            PROBE_FULLY_ENFORCED => Some(true),
4176            PROBE_PARTIAL => Some(false),
4177            // A `restrict_self` that errored tells us nothing either way, which is the same
4178            // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
4179            _ => None,
4180        }
4181    }
4182
4183    /// Runs inside the forked child, pre-exec: joins the session's network namespace through
4184    /// `user_ns_fd`/`net_ns_fd`, maps `/tmp` onto the session's temporary folder when
4185    /// `tmp_map_spec` is given, strips the child's Linux capabilities, installs the seccomp filter
4186    /// (the `socket()` domain denials keyed on `unix_sockets_allowed`, over a default-deny syscall
4187    /// allowlist), and (on `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope —
4188    /// which is also what enforces `capabilities.shell.allow`. Returning `Err` here aborts the
4189    /// exec (std's `Command` machinery propagates it back to the parent's `.spawn()` call as an
4190    /// `io::Error`) — the fail-closed path for setup failures that happen after fork.
4191    pub(super) fn child_install_enforcement(
4192        tier: EnforcementTier,
4193        user_ns_fd: RawFd,
4194        net_ns_fd: RawFd,
4195        tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4196        sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4197        landlock_fds: Option<&LandlockChildFds>,
4198        unix_sockets_allowed: bool,
4199    ) -> io::Result<()> {
4200        // FIRST, on every kernel tier: join the session's network namespace. It has to precede
4201        // everything below it for three separate reasons:
4202        //
4203        //   * `SECCOMP_MUST_STAY_DENIED` denies `setns`, so the filter installed further down
4204        //     closes the only window in which the join can happen — the same argument the
4205        //     composed root makes for `unshare`.
4206        //   * joining the user namespace hands this child every capability *inside* it, and
4207        //     `drop_all_capabilities` (below) strips them again before `execve`. Joining after
4208        //     that drop would fail: entering the network namespace needs `CAP_SYS_ADMIN` there.
4209        //   * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests its user
4210        //     namespace inside the session's. A task in a descendant user namespace holds no
4211        //     capability in the ancestor, so the capsule cannot reconfigure the network namespace
4212        //     it is confined to — which only holds if the join comes first.
4213        //
4214        // There is no path that skips it and falls back: a host that cannot create the namespace
4215        // was refused at launch (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`), and a
4216        // spawn whose namespace could not be established failed before the fork.
4217        //
4218        // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
4219        // documents itself as running in, and the one `join_session_netns` requires. Both
4220        // descriptors are held open by the join handle the `pre_exec` closure owns.
4221        unsafe { crate::network_namespace::join_session_netns(user_ns_fd, net_ns_fd) }.map_err(
4222            |error| {
4223                io::Error::other(format!(
4224                    "sandbox: joining this session's network namespace failed: {error}"
4225                ))
4226            },
4227        )?;
4228
4229        // NEXT, and only on a `MountNamespace` spawn: a private mount namespace, owned by the
4230        // session's user namespace, in which the session's temporary folder is bound over `/tmp`.
4231        // Here for the composed root's reason: the capabilities the join above handed out are
4232        // still held, and the seccomp filter below denies `unshare` and `mount`. A mapping that
4233        // cannot be built fails the spawn; nothing runs with an unmapped `/tmp` instead.
4234        if let Some(spec) = tmp_map_spec {
4235            #[cfg(test)]
4236            if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4237                return Err(io::Error::other(format!(
4238                    "{} (forced by test seam)",
4239                    spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4240                )));
4241            }
4242            // SAFETY: post-`fork()`, pre-exec child context, after `join_session_netns` handed
4243            // this child every capability in the session's user namespace and before the seccomp
4244            // filter — the window `map_private_tmp` requires. Every path and buffer it reads was
4245            // built in the parent and is borrowed from the `pre_exec` closure.
4246            unsafe { crate::temp_dir::map_private_tmp(spec) }
4247                .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4248        }
4249
4250        // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
4251        // composed root. This has to precede every *remaining* step in this closure, because
4252        // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
4253        // the filter below covers, permanently and deliberately.
4254        // The composed root is therefore built by this process while it still has its pre-filter
4255        // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
4256        // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
4257        // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
4258        // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
4259        // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
4260        if tier == EnforcementTier::KernelSealed {
4261            if let Some(spec) = sealed_spec {
4262                #[cfg(test)]
4263                if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4264                    return Err(io::Error::other(format!(
4265                        "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4266                        crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4267                    )));
4268                }
4269                crate::sealed::construct_composed_root(spec)
4270                    .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4271            }
4272        }
4273
4274        // Before anything else in this window, on every kernel tier: shut the fd-inheritance
4275        // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
4276        // ruleset installed further down this function — it can do nothing about a descriptor
4277        // the child already holds open at that point, because that fd was opened before the
4278        // ruleset existed and there is no operation left for the ruleset to intercept. Until
4279        // this call existed, the only thing stopping a spawned shell from inheriting an
4280        // arbitrary open fd was the empirical accident that nothing else happened to be open in
4281        // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
4282        // config read, a lock file, a log handle — would have leaked silently into every
4283        // subsequent subprocess, on every platform and every tier, with no test to catch it.
4284        //
4285        // Running it here is also what makes it cover this function's *own* descriptors: the
4286        // diagnostic pipe and the Landlock grant fds are used below but must never survive the
4287        // `execve`, and neither may the session's namespace handles, already used above.
4288        mark_inherited_fds_cloexec()?;
4289
4290        // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
4291        // capability model are independent, both-must-allow gates, so narrowing the Landlock
4292        // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
4293        // must also run *before* `install_seccomp_filter`, because it is what sets
4294        // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
4295        // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
4296        // failure is its own distinct, fail-closed error path before any filter is installed.
4297        drop_all_capabilities()?;
4298
4299        // The child stays *non-dumpable*, inherited from the runtime process
4300        // (`security::harden_process_dumpable`). Nothing reads the child's memory, so nothing
4301        // needs it dumpable. Marking it dumpable opens same-uid `ptrace`, `environ` and core-dump
4302        // access for the whole life of every shell subprocess: do not do so without a reader that
4303        // requires it.
4304        install_seccomp_filter(unix_sockets_allowed)?;
4305
4306        if matches!(
4307            tier,
4308            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4309        ) {
4310            if let Some(fds) = landlock_fds {
4311                apply_landlock_scope(fds).map_err(io::Error::other)?;
4312            }
4313        }
4314
4315        Ok(())
4316    }
4317
4318    /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
4319    /// the only descriptors that survive into the exec'd program are the ones the runtime
4320    /// deliberately set up (stdio) rather than whatever happened to be open.
4321    ///
4322    /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
4323    /// the required semantics here, not a softer variant of it: several fds in this range are
4324    /// still needed *inside* this `pre_exec` window — the diagnostic pipe a failure further down
4325    /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules from. Closing
4326    /// them here would break the very enforcement setup that follows; flagging them means they
4327    /// keep working until `execve`, which then closes them for us.
4328    ///
4329    /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
4330    /// stdio decision and its reasoning.
4331    ///
4332    /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
4333    /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
4334    /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
4335    /// so every spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
4336    /// now fails rather than silently running without fd hygiene. That is deliberate and it is
4337    /// this module's established fail-closed discipline (`drop_all_capabilities`,
4338    /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
4339    /// error rather than degrade), but it is a real, user-visible narrowing of the supported
4340    /// kernel range for kernel-enforcement tiers.
4341    pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
4342        // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
4343        // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
4344        // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
4345        // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
4346        // spelling from `close_range(2)`.
4347        let rc = unsafe {
4348            libc::syscall(
4349                libc::SYS_close_range,
4350                super::FD_HYGIENE_FIRST_FD,
4351                libc::c_uint::MAX,
4352                libc::CLOSE_RANGE_CLOEXEC,
4353            )
4354        };
4355        if rc != 0 {
4356            return Err(io::Error::last_os_error());
4357        }
4358        Ok(())
4359    }
4360
4361    /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
4362    /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
4363    /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
4364    /// only capabilities 0–31 and make the kernel log a deprecation warning.
4365    const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
4366
4367    /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
4368    /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
4369    /// capabilities — so this can never under-cover. Numbers the running kernel does not define
4370    /// return `EINVAL`, which the loop skips.
4371    const CAP_LAST_CAP_FALLBACK: u32 = 63;
4372
4373    /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
4374    /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
4375    /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
4376    /// `msghdr`/`cmsghdr` handling.
4377    ///
4378    /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
4379    /// handed to `capset(2)`; Rust itself only ever writes them.
4380    #[repr(C)]
4381    #[allow(dead_code)]
4382    struct CapUserHeader {
4383        version: u32,
4384        pid: libc::c_int,
4385    }
4386
4387    /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
4388    /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
4389    /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
4390    /// note.
4391    #[repr(C)]
4392    #[derive(Clone, Copy)]
4393    #[allow(dead_code)]
4394    struct CapUserData {
4395        effective: u32,
4396        permitted: u32,
4397        inheritable: u32,
4398    }
4399
4400    /// Strips every Linux capability from the forked child before `execve()`, in three steps
4401    /// whose **order matters**:
4402    ///
4403    ///   1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
4404    ///      still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
4405    ///      `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
4406    ///   2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
4407    ///      Shrinking your own permitted set to any subset — including empty — never requires
4408    ///      `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
4409    ///      inheritable also empties the *ambient* set, which the kernel maintains as a subset of
4410    ///      both — that is what covers a non-root capsule handed an unexpected ambient
4411    ///      `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
4412    ///   3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
4413    ///      file-capability binary, and so the seccomp filter installed next can load without
4414    ///      `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
4415    ///      longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
4416    ///      as a side effect.
4417    ///
4418    /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
4419    /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
4420    /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
4421    /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
4422    /// genuinely unprivileged caller, the bounding set would silently survive while this function
4423    /// still reported success.
4424    ///
4425    /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
4426    /// capability transition treats a file's permitted set as all-ones when the process's real
4427    /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
4428    /// would be undone by the very next `execve`; emptying the bounding set is what makes the
4429    /// post-exec permitted (and hence effective) set empty, which is what actually takes
4430    /// `CAP_MKNOD` away from the shell.
4431    ///
4432    /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
4433    /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
4434    /// — and strictly narrows what the subprocess can reach.
4435    fn drop_all_capabilities() -> io::Result<()> {
4436        drop_capability_bounding_set()?;
4437        clear_capability_sets()?;
4438        set_no_new_privs()
4439    }
4440
4441    /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
4442    ///
4443    ///   - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
4444    ///   - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
4445    ///     process that never held the capability being dropped either. Hard-failing here would
4446    ///     break every non-root `mur run`.
4447    ///
4448    /// Any other errno is unexpected and propagates, aborting the exec — this module's
4449    /// fail-closed invariant: no silently-unenforced spawn.
4450    #[allow(unsafe_code)]
4451    fn drop_capability_bounding_set() -> io::Result<()> {
4452        let zero: libc::c_ulong = 0;
4453
4454        for cap in 0..=kernel_last_cap() {
4455            let cap_arg = libc::c_ulong::from(cap);
4456            // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
4457            // ignored arguments — no pointers and no borrowed memory are involved.
4458            let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
4459            if rc == 0 {
4460                continue;
4461            }
4462
4463            let error = io::Error::last_os_error();
4464            match error.raw_os_error() {
4465                Some(libc::EINVAL) | Some(libc::EPERM) => continue,
4466                _ => return Err(error),
4467            }
4468        }
4469
4470        Ok(())
4471    }
4472
4473    /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
4474    /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
4475    /// everything. Any error is therefore unexpected and fails closed.
4476    #[allow(unsafe_code)]
4477    fn clear_capability_sets() -> io::Result<()> {
4478        let header = CapUserHeader {
4479            version: LINUX_CAPABILITY_VERSION_3,
4480            // 0 means "the calling thread" — the only pid `capset` accepts for a capability
4481            // change since Linux 2.6.25.
4482            pid: 0,
4483        };
4484        let data = [CapUserData {
4485            effective: 0,
4486            permitted: 0,
4487            inheritable: 0,
4488        }; 2];
4489
4490        // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
4491        // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
4492        // expect. The kernel only reads through them.
4493        let rc = unsafe {
4494            libc::syscall(
4495                libc::SYS_capset,
4496                &header as *const CapUserHeader,
4497                data.as_ptr(),
4498            )
4499        };
4500        if rc != 0 {
4501            return Err(io::Error::last_os_error());
4502        }
4503
4504        Ok(())
4505    }
4506
4507    /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
4508    /// process's privilege and is never refused for a well-formed call, so any error is
4509    /// unexpected and fails closed. Same call convention and fail-closed style as
4510    /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
4511    /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
4512    /// whole-process hardening.
4513    #[allow(unsafe_code)]
4514    fn set_no_new_privs() -> io::Result<()> {
4515        #[cfg(test)]
4516        if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
4517            return Err(io::Error::other(
4518                "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
4519            ));
4520        }
4521        // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
4522        // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
4523        // upper 32 bits would be unspecified in a variadic call on aarch64.
4524        let enable: libc::c_ulong = 1;
4525        let zero: libc::c_ulong = 0;
4526
4527        // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
4528        // and no borrowed memory are involved.
4529        let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
4530        if rc != 0 {
4531            return Err(io::Error::last_os_error());
4532        }
4533
4534        Ok(())
4535    }
4536
4537    /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
4538    /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
4539    /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
4540    /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
4541    /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
4542    #[allow(unsafe_code)]
4543    fn kernel_last_cap() -> u32 {
4544        const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
4545
4546        // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
4547        // only reads through the pointer.
4548        let fd = unsafe {
4549            libc::open(
4550                PATH.as_ptr() as *const libc::c_char,
4551                libc::O_RDONLY | libc::O_CLOEXEC,
4552            )
4553        };
4554        if fd < 0 {
4555            return CAP_LAST_CAP_FALLBACK;
4556        }
4557
4558        let mut buf = [0u8; 16];
4559        // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
4560        // is passed, so `read` cannot write past it.
4561        let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
4562        // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
4563        unsafe { libc::close(fd) };
4564
4565        if read <= 0 {
4566            return CAP_LAST_CAP_FALLBACK;
4567        }
4568
4569        let parsed = std::str::from_utf8(&buf[..read as usize])
4570            .ok()
4571            .and_then(|text| text.trim().parse::<u32>().ok());
4572
4573        match parsed {
4574            Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
4575            _ => CAP_LAST_CAP_FALLBACK,
4576        }
4577    }
4578
4579    /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
4580    /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
4581    /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
4582    /// does support Landlock, so this is a real combination, not a hypothetical one).
4583    ///
4584    /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
4585    /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
4586    /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
4587    const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
4588        ("landlock_create_ruleset", 444),
4589        ("landlock_add_rule", 445),
4590        ("landlock_restrict_self", 446),
4591    ];
4592
4593    /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
4594    /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
4595    /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
4596    /// held against and the headroom between them are recorded in
4597    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4598    ///
4599    /// **This filter raises no notifications and needs no supervisor.** Network enforcement lives
4600    /// in [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
4601    /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
4602    /// `Notify` rule may come back.
4603    ///
4604    /// One mechanism plus a default:
4605    ///
4606    ///   - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
4607    ///     a plain integer argument, so the comparison compiles straight into the loaded BPF
4608    ///     program and is evaluated in-kernel at syscall time: no notification is raised and no
4609    ///     memory of another task is read. That is what makes it structurally immune to the
4610    ///     pointer-read TOCTOU class of problem in the first place.
4611    ///   - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
4612    ///     neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
4613    ///     argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
4614    ///     `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
4615    ///     unreachable — they are simply absent, not argument-matched.
4616    fn build_seccomp_filter(
4617        unix_sockets_allowed: bool,
4618    ) -> io::Result<libseccomp::ScmpFilterContext> {
4619        // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
4620        // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
4621        // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
4622        // call's arguments and refused it", `EPERM` means "this syscall is not part of the
4623        // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
4624        // unmediated-surface problem from a policy decision.
4625        let mut filter =
4626            libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4627                .map_err(to_io_err)?;
4628
4629        // Ask the kernel to record every action this filter takes that is not `Allow` in the
4630        // audit trail, so a denial is attributable after the fact to a syscall number, pid and
4631        // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
4632        // has no channel for anything richer — so this is the only way an operator debugging a
4633        // workload that dies on an unexpected denial gets a syscall name to look at.
4634        //
4635        // Best-effort on purpose, and the one place in this function where an error is swallowed:
4636        // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
4637        // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
4638        // not an enforcement one — the default-deny action and every rule below are unaffected —
4639        // and turning it into a hard failure would take a host with an old libseccomp from
4640        // "denials are logged less legibly" to "no capsule can spawn a shell at all".
4641        //
4642        // Setting the attribute is necessary but not sufficient: the kernel only logs an action
4643        // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
4644        // configuration this process does not control.
4645        // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
4646        // these denials are verified by.
4647        let _ = filter.set_ctl_log(true);
4648
4649        // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
4650        // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
4651        // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
4652        // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
4653        // sees one consistent errno across both argument-level mechanisms.
4654        //
4655        // Untouched by the network-namespace work, and deliberately so: a network namespace does
4656        // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
4657        // still the only thing standing between a capsule and `/var/run/docker.sock`.
4658        //
4659        // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
4660        // adapt the rule to the architectures in the filter — on an arch where `socket` is
4661        // reached through `socketcall(2)` rather than as its own syscall, the exact form would
4662        // fail the whole install instead. Fail-closed is preserved either way, since any error
4663        // here propagates out of `pre_exec` and aborts the spawn.
4664        //
4665        // One rule per domain rather than one rule with three comparators: comparators within a
4666        // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
4667        // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
4668        let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
4669        for domain in super::denied_socket_domains(unix_sockets_allowed) {
4670            filter
4671                .add_rule_conditional(
4672                    libseccomp::ScmpAction::Errno(libc::EACCES),
4673                    socket_syscall,
4674                    &[libseccomp::ScmpArgCompare::new(
4675                        0,
4676                        libseccomp::ScmpCompareOp::Equal,
4677                        domain as u64,
4678                    )],
4679                )
4680                .map_err(to_io_err)?;
4681        }
4682
4683        // The permitted domains need positive rules of their own now that the filter's default is
4684        // a deny — without them `socket()` would be refused for every family, IP included.
4685        //
4686        // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
4687        // libseccomp resolves an unconditional rule for a syscall by *discarding* every
4688        // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
4689        // but the new filter has no chains so the existing chains must be cleared"), so a broad
4690        // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
4691        // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
4692        // default profile carries the same warning against combining a broad `socket` allow with
4693        // explicit errno rules, and works around it the same way — with argument-matched rules
4694        // only. The two domain sets are disjoint by construction (a test pins that), so no
4695        // `socket()` call can match both an allow and a deny rule.
4696        for domain in super::allowed_socket_domains(unix_sockets_allowed) {
4697            filter
4698                .add_rule_conditional(
4699                    libseccomp::ScmpAction::Allow,
4700                    socket_syscall,
4701                    &[libseccomp::ScmpArgCompare::new(
4702                        0,
4703                        libseccomp::ScmpCompareOp::Equal,
4704                        domain as u64,
4705                    )],
4706                )
4707                .map_err(to_io_err)?;
4708        }
4709
4710        // The allowlist proper. Two failure modes are both handled by skipping the name:
4711        //
4712        //   - `from_name` fails when this host's libseccomp does not know the syscall (a name
4713        //     newer than the installed library);
4714        //   - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
4715        //     not exist on this architecture — every legacy x86_64 spelling in the array
4716        //     (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
4717        //
4718        // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
4719        // so the filter can only ever come out *narrower* than intended, never wider. Propagating
4720        // instead would abort every single spawn on the first arch-specific name, which is why
4721        // this is the deliberate exception to the module's otherwise-strict error handling.
4722        for name in super::SECCOMP_SYSCALL_ALLOWLIST {
4723            if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
4724                let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
4725            }
4726        }
4727
4728        // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
4729        // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
4730        // deny would refuse the child's own filesystem scoping and abort every spawn on that
4731        // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
4732        // that installs it, never widen it, and Docker's default profile allows them too.
4733        //
4734        // Resolved by name where possible, with the syscall number as a fallback, because these
4735        // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
4736        // policy above would silently turn an older libseccomp into "no capsule can run a shell
4737        // on a Landlock-capable host". The numbers are the same on every architecture Linux has
4738        // added Landlock to (they were assigned after the syscall-number unification), so the
4739        // fallback is not architecture-specific.
4740        for (name, fallback_nr) in LANDLOCK_SYSCALLS {
4741            let syscall = libseccomp::ScmpSyscall::from_name(name)
4742                .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
4743            filter
4744                .add_rule(libseccomp::ScmpAction::Allow, syscall)
4745                .map_err(to_io_err)?;
4746        }
4747
4748        Ok(filter)
4749    }
4750
4751    /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
4752    /// the filter stays in force for the rest of this task's life and is inherited across the
4753    /// `execve` that follows, which is why nothing outside a forked child ever calls this.
4754    fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
4755        // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
4756        // on Drop) only frees the userspace filter-building context, while the filter itself is
4757        // already installed in the kernel and is inherited across the `execve` that follows.
4758        build_seccomp_filter(unix_sockets_allowed)?
4759            .load()
4760            .map_err(to_io_err)
4761    }
4762
4763    fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
4764        io::Error::other(error.to_string())
4765    }
4766
4767    /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
4768    /// instruction. Dividing an export's length by it is what turns bytes into an instruction
4769    /// count.
4770    #[cfg(test)]
4771    const SOCK_FILTER_BYTES: usize = 8;
4772
4773    /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
4774    /// returns what it wrote.
4775    ///
4776    /// Both exporters take a descriptor and nothing else, so the buffer has to come from
4777    /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
4778    /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
4779    /// crate and so is unavailable to anything outside a test build.
4780    #[cfg(test)]
4781    fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
4782    where
4783        F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
4784    {
4785        use std::io::{Read, Seek, SeekFrom};
4786        use std::os::fd::FromRawFd;
4787
4788        // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
4789        // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
4790        // the descriptor lives and dies inside this function.
4791        let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
4792        if raw < 0 {
4793            return Err(io::Error::last_os_error());
4794        }
4795        // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
4796        // is exactly the precondition for handing it to `OwnedFd`.
4797        let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
4798
4799        export(&memfd).map_err(to_io_err)?;
4800
4801        let mut file = std::fs::File::from(memfd);
4802        file.seek(SeekFrom::Start(0))?;
4803        let mut exported = Vec::new();
4804        file.read_to_end(&mut exported)?;
4805        Ok(exported)
4806    }
4807
4808    /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
4809    /// this host, for the given `unix_sockets_allowed`.
4810    ///
4811    /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
4812    /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
4813    /// divided by that record size. The number depends on the host — how many
4814    /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
4815    /// which is why it is measured here rather than written down.
4816    ///
4817    /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
4818    /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
4819    #[cfg(test)]
4820    fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
4821        let filter = build_seccomp_filter(unix_sockets_allowed)?;
4822        let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
4823        Ok(exported.len() / SOCK_FILTER_BYTES)
4824    }
4825
4826    /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
4827    /// one block per syscall carrying a rule, each block naming the argument comparisons and the
4828    /// action. It is the only view of a built context that shows the rule *shape* rather than the
4829    /// compiled program's size, which is what a test pinning the rule sequence needs.
4830    #[cfg(test)]
4831    fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
4832        let filter = build_seccomp_filter(unix_sockets_allowed)?;
4833        let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
4834        String::from_utf8(exported).map_err(to_io_err)
4835    }
4836
4837    /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
4838    ///
4839    ///   - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
4840    ///     FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
4841    ///     `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
4842    ///     [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
4843    ///     is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
4844    ///     the path it resolved itself, so nothing the capsule writes into its workdir can run;
4845    ///   - the session's temporary folder gets that **same** right-set from the same local
4846    ///     binding, whenever the enforcement holds one. The composed root and the mount-namespace
4847    ///     mapping both bind it at `/tmp` read-write, but Landlock matches an access to `/tmp/x`
4848    ///     against the bind's own root inode rather than the workdir's, so without this rule the
4849    ///     mount is writable and every access to it is nevertheless `EACCES` — bash heredocs,
4850    ///     `mktemp`, a compiler's intermediate objects and a package manager's build scratch all
4851    ///     fail. Identical rights are the honest ones: it is counted by the same
4852    ///     `workdir_max_bytes` guard, so it must be neither more nor less runnable than the
4853    ///     workdir itself;
4854    ///   - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
4855    ///     shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
4856    ///     `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
4857    ///     [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
4858    ///     grant — never write — with two bits added on top.
4859    ///     Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
4860    ///     exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
4861    ///     file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
4862    ///     whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
4863    ///     — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
4864    ///     (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
4865    ///     `true` everywhere except the two tier-issued sealed grants, which are deliberately
4866    ///     readable (and, on a directory, enumerable) but not runnable;
4867    ///   - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
4868    ///     and `Truncate` if its `writable` bit is set. That bit is set for exactly one path —
4869    ///     `/dev/null` — which is therefore the *only* writable path outside the workdir in the
4870    ///     whole sandbox. This list is a compile-time constant, not manifest-derived: no capability
4871    ///     declaration widens or narrows it. Every device path it does not name stays denied by the
4872    ///     ordinary no-matching-rule path below.
4873    ///
4874    /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
4875    /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
4876    /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
4877    /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
4878    /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
4879    /// so a write to any other outside path — including one of these read+execute grant paths — is
4880    /// still denied.
4881    ///
4882    /// The ruleset is built at `fds.abi` — the kernel's ABI capped at
4883    /// [`super::LANDLOCK_TARGET_ABI`], decided in the parent — or at ABI v1 when that is `None`.
4884    /// `handle_access` declares the **full** `from_all` set of that ABI, under `CompatLevel::HardRequirement`, and every rule's
4885    /// rights are masked to it first: a right the ABI does not know would otherwise fail
4886    /// `add_rule`, and masking it out is what a kernel of that ABI would enforce anyway. Built at
4887    /// v1, the ruleset is bit for bit the one a 5.13–5.18 kernel gets.
4888    ///
4889    /// `handle_access` must declare the union of every access bit any rule uses, and it is the
4890    /// full set deliberately: a bit declared there but granted by no rule is denied for
4891    /// the whole domain, which is exactly how the rights missing from
4892    /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
4893    /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
4894    /// path, on a host where one of the three is missing — that
4895    /// fails to *open* is skipped
4896    /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
4897    /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
4898    /// preserving the module's fail-closed invariant.
4899    fn apply_landlock_scope(fds: &LandlockChildFds) -> Result<(), String> {
4900        #[cfg(test)]
4901        if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
4902            return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
4903        }
4904
4905        use landlock::{
4906            Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
4907            RulesetCreatedAttr, ABI,
4908        };
4909
4910        let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
4911        let handled = AccessFs::from_all(abi);
4912        // Enough for the loader to open, read, and map-execute a binary or shared library, and to
4913        // open a file inside a granted directory by exact name — but no write access, and (without
4914        // `ReadDir`) no enumeration of the directory's own entries.
4915        let read_execute = AccessFs::Execute | AccessFs::ReadFile;
4916        // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
4917        let read_execute_list = read_execute | AccessFs::ReadDir;
4918        // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
4919        // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
4920        // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
4921        let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
4922        // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
4923        // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
4924        // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
4925        let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
4926        // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
4927        // not a directory) — only the data bits, with `WriteFile` reserved for the writable
4928        // devices. `Truncate` rides with `WriteFile` because a shell's `>/dev/null` opens with
4929        // `O_TRUNC`, and handling `Truncate` from ABI v3 would otherwise refuse it.
4930        let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
4931        let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
4932
4933        // The one writable right-set in this function, resolved once and used by both rules that
4934        // carry it. The session's temporary folder is counted by the workdir's size guard and is
4935        // the capsule's other scratch space, so it gets the same rights, `Execute` axis included:
4936        // `workdir_exec: false` must no more permit running a binary from `/tmp` than from the
4937        // workdir. Sharing the binding is what keeps that true; two separate calls could drift
4938        // apart.
4939        let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
4940
4941        // Every fd here was already opened in the parent (before fork). This function performs no
4942        // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
4943        // so a failure here can only be the kernel call itself, not an unrelated path resolution.
4944        let mut ruleset = Ruleset::default()
4945            .set_compatibility(CompatLevel::HardRequirement)
4946            .handle_access(handled)
4947            .map_err(|error| format!("landlock: handle_access failed: {error}"))?
4948            .create()
4949            .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
4950            .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
4951            .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
4952
4953        // The session's temporary folder, whenever the enforcement holds one. A rule on the
4954        // workdir does not reach a bind mounted over `/tmp`; see [`LandlockChildFds::temp_dir_fd`].
4955        if let Some(temp_dir_fd) = &fds.temp_dir_fd {
4956            ruleset = ruleset
4957                .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
4958                .map_err(|error| {
4959                    format!("landlock: add_rule for the session's temporary folder failed: {error}")
4960                })?;
4961        }
4962
4963        // The session's home folder, on exactly the temporary folder's terms: the workdir's
4964        // rights beneath the records directory's read-only rule.
4965        if let Some(home_dir_fd) = &fds.home_dir_fd {
4966            ruleset = ruleset
4967                .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
4968                .map_err(|error| {
4969                    format!("landlock: add_rule for the session's home folder failed: {error}")
4970                })?;
4971        }
4972
4973        // The runtime directory: readable and listable, never writable or executable. A native
4974        // tool's entrypoint inside it runs through its own pinned `Execute` grant.
4975        if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
4976            ruleset = ruleset
4977                .add_rule(PathBeneath::new(
4978                    runtime_dir_fd.as_fd(),
4979                    read_list & handled,
4980                ))
4981                .map_err(|error| {
4982                    format!("landlock: add_rule for the runtime directory failed: {error}")
4983                })?;
4984        }
4985
4986        // The records directory, on the same terms: the runtime appends to the trace from
4987        // outside the domain, and no command inside it can.
4988        if let Some(records_dir_fd) = &fds.records_dir_fd {
4989            ruleset = ruleset
4990                .add_rule(PathBeneath::new(
4991                    records_dir_fd.as_fd(),
4992                    read_list & handled,
4993                ))
4994                .map_err(|error| {
4995                    format!("landlock: add_rule for the records directory failed: {error}")
4996                })?;
4997        }
4998
4999        // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
5000        // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
5001        // the grant loop's rules for those two paths are taken on the host's files, not on these.
5002        for identity_fd in &fds.sealed_identity_fds {
5003            ruleset = ruleset
5004                .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5005                .map_err(|error| {
5006                    format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5007                })?;
5008        }
5009
5010        for grant in &fds.grants {
5011            // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
5012            // so every fd reaching here is valid. A rule that still fails to add is a genuine
5013            // ruleset-construction failure and propagates as `Err` (fail-closed).
5014            let access = match (grant.executable, grant.list_dir) {
5015                (true, true) => read_execute_list,
5016                (true, false) => read_execute,
5017                (false, true) => read_list,
5018                (false, false) => read_only,
5019            };
5020            ruleset = ruleset
5021                .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5022                .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5023        }
5024
5025        for device in &fds.devices {
5026            // Same convention as the grant loop above: a device that failed to open in the parent
5027            // was already dropped, so a failure here is a genuine ruleset-construction failure and
5028            // propagates as `Err` (fail-closed).
5029            let access = if device.writable {
5030                device_read_write
5031            } else {
5032                device_read
5033            };
5034            ruleset = ruleset
5035                .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5036                .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5037        }
5038
5039        ruleset
5040            .restrict_self()
5041            .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
5042
5043        Ok(())
5044    }
5045
5046    /// What [`open_landlock_fds`] opens, or carries through, for one launch.
5047    pub(super) struct LandlockFdRequest<'a> {
5048        /// The workdir the capsule's commands run in. Fail-closed: a workdir that cannot be
5049        /// opened is an `Err` naming the path.
5050        pub(super) accessible_workdir: &'a Path,
5051        /// The manifest-derived grants. A grant path that fails to open is dropped
5052        /// (shrink-not-fail).
5053        pub(super) landlock_grants: &'a [LandlockGrant],
5054        /// The tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on `KernelFull` and the
5055        /// composed root's own OCI default set on `KernelSealed`, chosen by
5056        /// `super::landlock_device_grants`. Opened by the grants' rules: the list is a
5057        /// compile-time constant rather than manifest-derived, and a host where one entry cannot
5058        /// be opened — unusual, but a broken or minimal `/dev` is possible — loses that one device
5059        /// rather than failing the launch. A lost device, `/dev/null` included, narrows the
5060        /// capsule's scope and never widens it.
5061        pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
5062        /// Carried into [`LandlockChildFds::workdir_exec`].
5063        pub(super) workdir_exec: bool,
5064        /// Carried into [`LandlockChildFds::abi`].
5065        pub(super) abi: Option<u8>,
5066        /// What only `KernelSealed` opens: the synthetic `/etc` files. Fail-closed.
5067        pub(super) sealed: Option<&'a super::SealedSourceFds>,
5068        /// The session's temporary folder's descriptor on both Landlock tiers, which
5069        /// `prepare_enforcement` already took and shares with the sealed binds. Fail-closed — an
5070        /// `Err` naming the path, exactly like the workdir's own open, never the grant loops'
5071        /// shrink-not-fail `continue`.
5072        pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5073        /// The session's home folder's descriptor, on the same terms as [`Self::temp_dir_fd`].
5074        pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5075        /// The runtime directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
5076        pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5077        /// The records directory's descriptor, on the same terms as [`Self::temp_dir_fd`].
5078        pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5079    }
5080
5081    impl<'a> LandlockFdRequest<'a> {
5082        /// `accessible_workdir` and nothing else: no grants, no devices, no sealed sources and
5083        /// no directory fds.
5084        #[cfg(test)]
5085        pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
5086            Self {
5087                accessible_workdir,
5088                landlock_grants: &[],
5089                device_grants: &[],
5090                workdir_exec: false,
5091                abi: None,
5092                sealed: None,
5093                temp_dir_fd: None,
5094                home_dir_fd: None,
5095                runtime_dir_fd: None,
5096                records_dir_fd: None,
5097            }
5098        }
5099    }
5100
5101    /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
5102    /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
5103    /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
5104    /// now fails here, synchronously, with a message naming the path — before any subprocess is
5105    /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
5106    /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
5107    ///
5108    /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
5109    /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
5110    /// only, never widening.
5111    ///
5112    /// The request's devices are opened by the same rules as its grants, and its directory fds
5113    /// move into the returned [`LandlockChildFds`]. See [`LandlockFdRequest`] for each field.
5114    pub(super) fn open_landlock_fds(
5115        request: LandlockFdRequest<'_>,
5116    ) -> Result<LandlockChildFds, String> {
5117        let LandlockFdRequest {
5118            accessible_workdir,
5119            landlock_grants,
5120            device_grants,
5121            workdir_exec,
5122            abi,
5123            sealed,
5124            temp_dir_fd,
5125            home_dir_fd,
5126            runtime_dir_fd,
5127            records_dir_fd,
5128        } = request;
5129        let workdir_fd = open_o_path(accessible_workdir)
5130            .map(OwnedFd::from)
5131            .map_err(|error| {
5132                format!(
5133                    "sandbox: failed to open workdir {} for Landlock scoping: {error}",
5134                    accessible_workdir.display()
5135                )
5136            })?;
5137
5138        // Same tier gate, same fail-closed handling: the composed root binds these two files at
5139        // `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of those
5140        // names, whose inodes the capsule never sees. See `LandlockChildFds`. `sealed-etc/` is
5141        // written by the runtime alone.
5142        let mut sealed_identity_fds = Vec::new();
5143        for path in sealed
5144            .map(|sources| sources.identity_files.as_slice())
5145            .unwrap_or_default()
5146        {
5147            let file = open_o_path(path).map_err(|error| {
5148                format!(
5149                    "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
5150                     {error}",
5151                    path.display()
5152                )
5153            })?;
5154            sealed_identity_fds.push(OwnedFd::from(file));
5155        }
5156
5157        let mut grants = Vec::with_capacity(landlock_grants.len());
5158        for grant in landlock_grants {
5159            match open_o_path(&grant.path) {
5160                Ok(file)
5161                    if grant.pinned.is_some_and(|pinned| {
5162                        file.metadata()
5163                            .map(|meta| super::FileIdentity::of(&meta) != pinned)
5164                            .unwrap_or(true)
5165                    }) =>
5166                {
5167                    continue
5168                }
5169                Ok(file) => grants.push(OpenLandlockGrant {
5170                    // `ReadDir` is meaningful only on a directory, and the kernel does not merely
5171                    // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
5172                    // directory-only right on a non-directory, and the rule loop in
5173                    // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
5174                    // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
5175                    // `interpreter_runtime` entry, or the static `directory` bit on a
5176                    // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
5177                    // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
5178                    // never add it.
5179                    list_dir: grant.list_dir
5180                        && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
5181                    fd: OwnedFd::from(file),
5182                    executable: grant.executable,
5183                }),
5184                Err(_) => continue,
5185            }
5186        }
5187
5188        let mut devices = Vec::with_capacity(device_grants.len());
5189        for device in device_grants {
5190            match open_o_path(Path::new(device.path)) {
5191                Ok(file) => devices.push(OpenDeviceGrant {
5192                    fd: OwnedFd::from(file),
5193                    writable: device.writable,
5194                }),
5195                Err(_) => continue,
5196            }
5197        }
5198
5199        Ok(LandlockChildFds {
5200            workdir_fd,
5201            workdir_exec,
5202            abi,
5203            temp_dir_fd,
5204            home_dir_fd,
5205            sealed_identity_fds,
5206            runtime_dir_fd,
5207            records_dir_fd,
5208            grants,
5209            devices,
5210        })
5211    }
5212
5213    /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
5214    /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
5215    /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
5216    /// closure.
5217    ///
5218    /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
5219    /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
5220    /// not perform a second path lookup to get it.
5221    fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
5222        use std::os::unix::fs::OpenOptionsExt;
5223        std::fs::OpenOptions::new()
5224            .read(true)
5225            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
5226            .open(path)
5227    }
5228
5229    /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
5230    /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
5231    /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
5232    /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
5233    /// returns `Err`, so `execve` is still aborted).
5234    #[allow(unsafe_code)]
5235    pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
5236        let bytes = message.as_bytes();
5237        let len = bytes.len().min(MAX_DIAG_LEN);
5238        let mut written = 0;
5239        while written < len {
5240            // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
5241            // name a live sub-slice of `bytes` for the duration of this single `write` call.
5242            let n = unsafe {
5243                libc::write(
5244                    fd,
5245                    bytes[written..len].as_ptr() as *const libc::c_void,
5246                    len - written,
5247                )
5248            };
5249            if n < 0 {
5250                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5251                    continue;
5252                }
5253                break;
5254            }
5255            if n == 0 {
5256                break;
5257            }
5258            written += n as usize;
5259        }
5260    }
5261
5262    /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
5263    /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
5264    /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
5265    /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
5266    /// failure that never ran `pre_exec`).
5267    #[allow(unsafe_code)]
5268    pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
5269        let mut buf = Vec::new();
5270        let mut chunk = [0u8; 256];
5271        loop {
5272            // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
5273            // stack buffer of exactly the length passed.
5274            let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
5275            if n < 0 {
5276                if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5277                    continue;
5278                }
5279                break;
5280            }
5281            if n == 0 {
5282                break;
5283            }
5284            buf.extend_from_slice(&chunk[..n as usize]);
5285            if buf.len() >= MAX_DIAG_LEN {
5286                break;
5287            }
5288        }
5289        if buf.is_empty() {
5290            None
5291        } else {
5292            Some(String::from_utf8_lossy(&buf).into_owned())
5293        }
5294    }
5295
5296    /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
5297    /// against — all four numbers produced by running code on the host under test, never quoted.
5298    ///
5299    /// The written record these tests feed is
5300    /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
5301    ///
5302    /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
5303    /// irreversible for the life of the task, so an in-process load would silently apply to the
5304    /// rest of the test binary and to everything it spawns. Every load happens in a forked child
5305    /// that does nothing else and then `_exit`s.
5306    #[cfg(test)]
5307    #[allow(clippy::print_stdout, clippy::print_stderr)]
5308    mod seccomp_budget {
5309        use std::io;
5310        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
5311
5312        // `seccomp_filter_instruction_count` is absent from this list because the test that
5313        // reports it carries the same name; it is called as `super::` throughout.
5314        use super::{
5315            build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
5316            LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
5317        };
5318        use crate::sandbox::{
5319            allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
5320            EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
5321            SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
5322        };
5323
5324        /// The kernel charges each filter already attached to a task four instructions on top of
5325        /// its own length when it decides whether the next attach fits under
5326        /// [`SECCOMP_MAX_INSNS_PER_PATH`].
5327        ///
5328        /// It is only part of the arithmetic. The length the kernel adds up is that of the
5329        /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
5330        /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
5331        /// out slightly longer. So a load count predicted from the exported classic length is an
5332        /// upper bound on the load count the kernel actually allows, never an equality, which is
5333        /// why the cumulative probe brackets it rather than pinning it.
5334        const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
5335
5336        /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
5337        /// Every real errno is a small positive number, and 0 is reserved for a load that
5338        /// succeeded, so this cannot collide with either.
5339        const PROBE_ERRNO_UNKNOWN: i32 = 254;
5340
5341        /// A filter built only to be measured or refused: default `Allow`, and `rules`
5342        /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
5343        ///
5344        /// Default `Allow` rather than the real filter's default deny, for two reasons that both
5345        /// matter. A child has to survive its own load long enough to `_exit` and, in the
5346        /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
5347        /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
5348        /// equals the default action, so the rules have to carry an action the default does not:
5349        /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
5350        /// every rule in the program.
5351        ///
5352        /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
5353        /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
5354        /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
5355        fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
5356            let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
5357                .map_err(to_io_err)?;
5358            filter.set_api_sysrawrc(true).map_err(to_io_err)?;
5359
5360            let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
5361            for value in 0..rules {
5362                filter
5363                    .add_rule_conditional(
5364                        libseccomp::ScmpAction::Errno(libc::EPERM),
5365                        syscall,
5366                        &[libseccomp::ScmpArgCompare::new(
5367                            0,
5368                            libseccomp::ScmpCompareOp::Equal,
5369                            value as u64,
5370                        )],
5371                    )
5372                    .map_err(to_io_err)?;
5373            }
5374
5375            let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5376            Ok((filter, exported.len() / SOCK_FILTER_BYTES))
5377        }
5378
5379        /// The smallest probe-filter rule count whose compiled program is longer than
5380        /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
5381        /// monotonically with the rule count, so both halves are sound and the whole search
5382        /// costs a couple of dozen exports rather than thousands.
5383        fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
5384            let mut under = 1;
5385            let mut over = 8;
5386            while probe_filter(over)?.1 <= instructions {
5387                under = over;
5388                over *= 2;
5389                assert!(
5390                    over <= 1 << 20,
5391                    "no probe filter of up to {over} rules exceeded {instructions} instructions — \
5392                     the probe is not growing the program the way this search assumes"
5393                );
5394            }
5395            while over - under > 1 {
5396                let mid = under + (over - under) / 2;
5397                if probe_filter(mid)?.1 <= instructions {
5398                    under = mid;
5399                } else {
5400                    over = mid;
5401                }
5402            }
5403            Ok(over)
5404        }
5405
5406        /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
5407        /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
5408        /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
5409        /// only ever sees a plain `libc` value.
5410        fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
5411            use libseccomp::error::SeccompErrno;
5412
5413            if let Some(raw) = error.sysrawrc() {
5414                return -raw;
5415            }
5416            match error.errno() {
5417                Some(SeccompErrno::EACCES) => libc::EACCES,
5418                Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
5419                Some(SeccompErrno::EINVAL) => libc::EINVAL,
5420                Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
5421                _ => PROBE_ERRNO_UNKNOWN,
5422            }
5423        }
5424
5425        /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
5426        /// that took, `Err(errno)` for one the kernel refused.
5427        ///
5428        /// The filter is built by the caller, before the fork, so the child's whole post-fork
5429        /// window is `load()` and `_exit`.
5430        fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
5431            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
5432            // child confines itself to work that does not depend on another thread's state. This
5433            // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
5434            // the BPF program on every load and does not cache the one `export_bpf` produced —
5435            // so this probe is not async-signal-safe in the strict sense; it is bounded to a
5436            // child that touches nothing else and dies immediately.
5437            let pid = unsafe { libc::fork() };
5438            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5439
5440            if pid == 0 {
5441                let code = match filter.load() {
5442                    Ok(()) => 0,
5443                    Err(error) => refusal_errno(&error),
5444                };
5445                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
5446                // which is what keeps libtest's state in the parent untouched.
5447                unsafe { libc::_exit(code) }
5448            }
5449
5450            let mut status: libc::c_int = 0;
5451            // SAFETY: `pid` is the child just forked; `status` is a live local.
5452            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5453            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5454            assert!(
5455                libc::WIFEXITED(status),
5456                "the load probe child did not exit normally (status {status})"
5457            );
5458            match libc::WEXITSTATUS(status) {
5459                0 => Ok(()),
5460                errno => Err(errno),
5461            }
5462        }
5463
5464        /// The number of distinct syscalls the allowlist actually contributes a rule for on this
5465        /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
5466        /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
5467        /// is an independent measurement rather than a restatement of the filter under test.
5468        fn resolvable_allowlist_syscalls() -> io::Result<usize> {
5469            use std::collections::HashSet;
5470
5471            let mut scratch =
5472                libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5473                    .map_err(to_io_err)?;
5474            let mut accepted = HashSet::new();
5475            for name in SECCOMP_SYSCALL_ALLOWLIST {
5476                if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5477                    if scratch
5478                        .add_rule(libseccomp::ScmpAction::Allow, syscall)
5479                        .is_ok()
5480                    {
5481                        accepted.insert(i32::from(syscall));
5482                    }
5483                }
5484            }
5485            Ok(accepted.len())
5486        }
5487
5488        /// The line both ceiling probes print instead of running, on a host where no seccomp
5489        /// filter can be loaded at all.
5490        fn skip_without_seccomp(what: &str) -> bool {
5491            let tier = detect_enforcement_tier();
5492            if tier == EnforcementTier::EnvironmentOnly {
5493                eprintln!(
5494                    "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
5495                     can load a seccomp filter at all, which is every tier above \
5496                     EnforcementTier::EnvironmentOnly; detected {tier:?}"
5497                );
5498                return true;
5499            }
5500            false
5501        }
5502
5503        /// What the real filter compiles to, against the budget this project holds itself to and
5504        /// the ceiling the kernel holds it to.
5505        ///
5506        /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
5507        /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
5508        /// cover the larger of the two.
5509        #[test]
5510        fn seccomp_filter_instruction_count() {
5511            for unix_sockets_allowed in [false, true] {
5512                let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
5513                    .expect("export the built seccomp filter");
5514
5515                eprintln!(
5516                    "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
5517                     -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
5518                     per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
5519                     cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
5520                    BPF_MAXINSNS_CEILING as f64 / count as f64,
5521                    SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
5522                );
5523
5524                assert!(
5525                    count > 0,
5526                    "the exported filter is empty, so nothing was measured — \
5527                     `export_bpf` wrote no `struct sock_filter` records at all"
5528                );
5529                assert!(
5530                    count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
5531                    "the child's seccomp filter compiles to {count} instructions with \
5532                     unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
5533                     {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
5534                     {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
5535                     still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
5536                     deliberately — updating \
5537                     docs/content/reference/seccomp-filter-instruction-budget.md with the new \
5538                     numbers — rather than letting the filter grow into a spawn failure"
5539                );
5540            }
5541        }
5542
5543        /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
5544        /// pseudo-filter-code export so a change to the shape is a test failure rather than a
5545        /// silent change of policy.
5546        ///
5547        /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
5548        /// anywhere in this function would make libseccomp discard every argument-conditional
5549        /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
5550        /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
5551        #[test]
5552        fn seccomp_filter_rule_shape() {
5553            let allowlist_syscalls =
5554                resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
5555            assert!(
5556                allowlist_syscalls > 0,
5557                "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
5558                 for the wrong reason"
5559            );
5560
5561            for unix_sockets_allowed in [false, true] {
5562                let filter =
5563                    build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
5564                assert_eq!(
5565                    filter.get_act_default().expect("read the default action"),
5566                    libseccomp::ScmpAction::Errno(libc::EPERM),
5567                    "the filter's default action is no longer a deny with EPERM, which is what \
5568                     makes every syscall outside the allowlist unreachable"
5569                );
5570
5571                let pfc =
5572                    seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
5573                let denied = denied_socket_domains(unix_sockets_allowed);
5574                let allowed = allowed_socket_domains(unix_sockets_allowed);
5575
5576                // One block per syscall carrying at least one rule: `socket`, every allowlist
5577                // name libseccomp accepted, and the three Landlock syscalls.
5578                let syscall_blocks = pfc.matches("# filter for syscall ").count();
5579                assert_eq!(
5580                    syscall_blocks,
5581                    1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5582                    "the built filter carries rules for {syscall_blocks} syscalls, not the \
5583                     socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
5584                     syscalls this code adds",
5585                    LANDLOCK_SYSCALLS.len()
5586                );
5587
5588                let deny_actions = pfc
5589                    .matches(&format!("action ERRNO({});", libc::EACCES))
5590                    .count();
5591                assert_eq!(
5592                    deny_actions,
5593                    denied.len(),
5594                    "the built filter carries {deny_actions} EACCES rules, not one per denied \
5595                     socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
5596                     exactly like this, and would have discarded every domain denial with it"
5597                );
5598
5599                // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
5600                // contributes one per allowed domain.
5601                let allow_actions = pfc.matches("action ALLOW;").count();
5602                assert_eq!(
5603                    allow_actions,
5604                    allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5605                    "the built filter carries {allow_actions} Allow rules, not one per allowed \
5606                     socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
5607                     Landlock syscall"
5608                );
5609
5610                for (name, _) in LANDLOCK_SYSCALLS {
5611                    assert!(
5612                        pfc.contains(&format!("\"{name}\"")),
5613                        "{name} has no rule in the built filter, so `apply_landlock_scope` would \
5614                         be refused by the filter installed just before it"
5615                    );
5616                }
5617
5618                eprintln!(
5619                    "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
5620                     default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
5621                     {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
5622                    denied.len(),
5623                    allowed.len(),
5624                    LANDLOCK_SYSCALLS.len(),
5625                );
5626            }
5627        }
5628
5629        /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
5630        /// two filters that straddle it and observing that the kernel takes one and refuses the
5631        /// other with `EINVAL`.
5632        ///
5633        /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
5634        /// ceiling, whatever the filter's length.
5635        #[test]
5636        fn seccomp_per_filter_ceiling() {
5637            if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
5638                return;
5639            }
5640
5641            let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
5642                .expect("bracket the per-filter ceiling");
5643            let (under_filter, under_count) =
5644                probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
5645            let (over_filter, over_count) =
5646                probe_filter(over_rules).expect("build the over-ceiling probe filter");
5647
5648            assert!(
5649                under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
5650                "the two probe filters ({under_count} and {over_count} instructions) do not \
5651                 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
5652                 nothing about where the ceiling is"
5653            );
5654
5655            let under_outcome = load_in_child(&under_filter);
5656            let over_outcome = load_in_child(&over_filter);
5657
5658            eprintln!(
5659                "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
5660                 instructions; smallest that did not = {over_count} instructions, refused with \
5661                 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
5662                over_outcome.err().unwrap_or(0),
5663                libc::EINVAL,
5664            );
5665
5666            assert_eq!(
5667                under_outcome,
5668                Ok(()),
5669                "a {under_count}-instruction filter was refused, below the \
5670                 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
5671            );
5672            assert_eq!(
5673                over_outcome,
5674                Err(libc::EINVAL),
5675                "a {over_count}-instruction filter was not refused with EINVAL, so \
5676                 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
5677                 per-filter limit"
5678            );
5679        }
5680
5681        /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
5682        /// task carries, by stacking filters in a forked child until the kernel refuses.
5683        ///
5684        /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
5685        /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
5686        /// filter of a few hundred instructions, and that had not yet reached `load()` when it
5687        /// failed, did not come from the kernel's seccomp code.
5688        #[test]
5689        fn seccomp_cumulative_ceiling() {
5690            if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
5691                return;
5692            }
5693
5694            // A probe of roughly the shipped filter's size, so the number of stacked loads reads
5695            // directly as "how many filters like ours one task could carry".
5696            let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
5697            let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
5698            // Enough headroom past the ceiling that a kernel which never refuses is caught by the
5699            // cap rather than by the test hanging.
5700            let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
5701
5702            let mut fds = [0 as libc::c_int; 2];
5703            // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
5704            let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
5705            assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
5706            // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
5707            let (read_fd, write_fd) =
5708                unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
5709
5710            // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
5711            // child confines itself to work that does not depend on another thread's state. This
5712            // child calls `load()` in a loop, writes eight bytes and `_exit`s.
5713            let pid = unsafe { libc::fork() };
5714            assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5715
5716            if pid == 0 {
5717                let mut loads: u32 = 0;
5718                let mut refusal: i32 = 0;
5719                while loads < max_loads as u32 {
5720                    match filter.load() {
5721                        Ok(()) => loads += 1,
5722                        Err(error) => {
5723                            refusal = refusal_errno(&error);
5724                            break;
5725                        }
5726                    }
5727                }
5728
5729                let mut report = [0u8; 8];
5730                report[..4].copy_from_slice(&loads.to_ne_bytes());
5731                report[4..].copy_from_slice(&refusal.to_ne_bytes());
5732                // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
5733                // local of exactly the length passed, and the descriptor is the pipe's write end.
5734                unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
5735                // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
5736                unsafe { libc::_exit(0) }
5737            }
5738
5739            drop(write_fd);
5740            let mut report = [0u8; 8];
5741            std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
5742                .expect("read the cumulative probe child's report");
5743            let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
5744            let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
5745
5746            let mut status: libc::c_int = 0;
5747            // SAFETY: `pid` is the child just forked; `status` is a live local.
5748            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5749            assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5750
5751            let stacked = loads * count;
5752            let cumulative_at_refusal = count + loads * per_filter_cost;
5753            let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
5754            let shipped = super::seccomp_filter_instruction_count(false)
5755                .expect("measure the shipped filter for comparison");
5756
5757            eprintln!(
5758                "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
5759                 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
5760                 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
5761                 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
5762                 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
5763                 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
5764                 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
5765                 instructions, so reaching this ceiling would take {} of them in a single task.",
5766                loads + 1,
5767                libc::ENOMEM,
5768                SECCOMP_MAX_INSNS_PER_PATH / shipped,
5769            );
5770
5771            assert!(
5772                loads >= 2,
5773                "the kernel refused the filter chain after only {loads} load(s), so this is not \
5774                 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
5775            );
5776            assert_eq!(
5777                refusal,
5778                libc::ENOMEM,
5779                "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
5780                 {refusal}, not ENOMEM ({}) — the rule-out in \
5781                 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
5782                 being what this ceiling returns",
5783                libc::ENOMEM
5784            );
5785            assert!(
5786                loads <= loads_if_counted_classic,
5787                "the kernel took {loads} loads of a {count}-instruction filter — more than the \
5788                 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
5789                 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
5790                 each one, so this kernel's ceiling is higher than the recorded value"
5791            );
5792            // Loose on purpose: the exported classic length under-counts what the kernel adds
5793            // up, so the bound below tolerates any plausible conversion expansion while still
5794            // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
5795            // say, BPF_MAXINSNS would fail here.
5796            assert!(
5797                loads * 2 > loads_if_counted_classic,
5798                "the kernel refused after {loads} loads of a {count}-instruction filter, far \
5799                 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
5800                 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
5801                 one recorded"
5802            );
5803            assert!(
5804                shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
5805                "the shipped filter ({shipped} instructions) is within a factor of two of the \
5806                 cumulative ceiling, which would make the rule-out this test supports untrue"
5807            );
5808        }
5809    }
5810}
5811
5812/// Builds a minimal but well-formed ELF64 image exercising exactly the fields
5813/// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
5814/// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
5815/// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
5816#[cfg(test)]
5817pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
5818    let has_dynamic = !needed.is_empty() || runpath.is_some();
5819
5820    let mut phnum: u16 = 1; // PT_LOAD
5821    if interp.is_some() {
5822        phnum += 1;
5823    }
5824    if has_dynamic {
5825        phnum += 1;
5826    }
5827
5828    let ehsize = 64usize;
5829    let phentsize = 56usize;
5830    let phoff = ehsize;
5831    let body_start = phoff + phentsize * phnum as usize;
5832
5833    // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
5834    let mut strtab: Vec<u8> = vec![0];
5835    let mut needed_offsets = Vec::new();
5836    for name in needed {
5837        needed_offsets.push(strtab.len() as u64);
5838        strtab.extend_from_slice(name.as_bytes());
5839        strtab.push(0);
5840    }
5841    let runpath_offset = runpath.map(|r| {
5842        let off = strtab.len() as u64;
5843        strtab.extend_from_slice(r.as_bytes());
5844        strtab.push(0);
5845        off
5846    });
5847
5848    let mut body: Vec<u8> = Vec::new();
5849
5850    let interp_offset = body_start + body.len();
5851    if let Some(i) = interp {
5852        body.extend_from_slice(i.as_bytes());
5853        body.push(0);
5854    }
5855
5856    // Align the dynamic array to 8 bytes.
5857    while !(body_start + body.len()).is_multiple_of(8) {
5858        body.push(0);
5859    }
5860    let dyn_offset = body_start + body.len();
5861    let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
5862    let dyn_size = num_dyn * 16;
5863    let strtab_offset = dyn_offset + dyn_size;
5864
5865    if has_dynamic {
5866        let mut push_dyn = |tag: u64, val: u64| {
5867            body.extend_from_slice(&tag.to_le_bytes());
5868            body.extend_from_slice(&val.to_le_bytes());
5869        };
5870        for &o in &needed_offsets {
5871            push_dyn(1, o); // DT_NEEDED
5872        }
5873        if let Some(ro) = runpath_offset {
5874            push_dyn(29, ro); // DT_RUNPATH
5875        }
5876        push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
5877        push_dyn(10, strtab.len() as u64); // DT_STRSZ
5878        push_dyn(0, 0); // DT_NULL
5879        assert_eq!(body_start + body.len(), strtab_offset);
5880        body.extend_from_slice(&strtab);
5881    }
5882
5883    let total_len = body_start + body.len();
5884    let mut out = vec![0u8; total_len];
5885    out[0..4].copy_from_slice(b"\x7fELF");
5886    out[4] = 2; // ELFCLASS64
5887    out[5] = 1; // little-endian
5888    out[6] = 1; // EI_VERSION
5889    out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
5890    out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
5891    out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
5892    out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
5893    out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
5894    out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
5895
5896    let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
5897    phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
5898    if let Some(i) = interp {
5899        phdrs.push((
5900            3,
5901            interp_offset as u64,
5902            interp_offset as u64,
5903            i.len() as u64 + 1,
5904        ));
5905    }
5906    if has_dynamic {
5907        phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
5908    }
5909    for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
5910        let base = phoff + idx * phentsize;
5911        out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
5912        out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
5913        out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
5914        out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
5915    }
5916
5917    out[body_start..total_len].copy_from_slice(&body);
5918    out
5919}
5920
5921#[cfg(test)]
5922mod tests {
5923    use super::*;
5924    use murmur_artifact::security_warnings::W_SEC_003;
5925
5926    /// Content check only: this asserts the *constant*, not the security property. Spawning a
5927    /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
5928    /// enforcement, so it would read as evidence while proving nothing.
5929    ///
5930    /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
5931    /// breaks every subprocess invocation.
5932    #[test]
5933    fn fd_hygiene_range_starts_above_stdio() {
5934        assert_eq!(
5935            FD_HYGIENE_FIRST_FD, 3,
5936            "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
5937             survive execve"
5938        );
5939    }
5940
5941    /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
5942    /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
5943    /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
5944    /// runs without fd hygiene.
5945    ///
5946    /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
5947    /// argument validation before it touches any descriptor — so this exercises the errno path
5948    /// without closing or flagging a single fd of the test process. A kernel older than 5.11
5949    /// rejects the production call with a different errno and the identical convention — see the
5950    /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
5951    #[cfg(target_os = "linux")]
5952    #[allow(unsafe_code)]
5953    #[test]
5954    fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
5955        // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
5956        // bit is set here, so the kernel rejects the call on argument validation and no descriptor
5957        // of this process is closed or marked close-on-exec.
5958        let rc = unsafe {
5959            libc::syscall(
5960                libc::SYS_close_range,
5961                FD_HYGIENE_FIRST_FD,
5962                libc::c_uint::MAX,
5963                libc::c_uint::MAX,
5964            )
5965        };
5966
5967        assert_eq!(rc, -1, "an invalid flag word must not be accepted");
5968        assert_eq!(
5969            std::io::Error::last_os_error().raw_os_error(),
5970            Some(libc::EINVAL),
5971            "the two statements the production path runs on failure must carry the kernel's errno"
5972        );
5973    }
5974
5975    /// A host whose probe created all three namespaces — read under the AppArmor restriction with
5976    /// `mur` unconfined, because the readings never decide anything.
5977    fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
5978        crate::namespace_probe::NamespaceReading {
5979            network: Ok(()),
5980            mount: crate::namespace_probe::MountAttempt::Created,
5981            sealed_root: crate::namespace_probe::MountAttempt::Created,
5982            userns_grant: crate::sealed::UsernsGrant::Withheld,
5983            attachment: crate::sealed::ProfileAttachment::Unconfined,
5984        }
5985    }
5986
5987    /// A host whose probe could not run at all, which claims nothing.
5988    fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
5989        crate::namespace_probe::NamespaceReading {
5990            network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
5991                "the namespace creator did not run".to_string(),
5992            )),
5993            mount: crate::namespace_probe::MountAttempt::NotAttempted,
5994            sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
5995            userns_grant: crate::sealed::UsernsGrant::Withheld,
5996            attachment: crate::sealed::ProfileAttachment::NotReported,
5997        }
5998    }
5999
6000    /// `mur doctor`'s native-tool mechanism is read from the decision on the reading it is handed:
6001    /// on a sealed-capable reading a capsule declaring `scoped` runs its native tools on
6002    /// `landlock+seccomp` and one declaring `sealed` on the composed root.
6003    #[test]
6004    fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
6005        if !cfg!(target_os = "linux") {
6006            return;
6007        }
6008        let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
6009        assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
6010        assert_eq!(
6011            crate::containment::applied_mechanism(
6012                &sealed_host,
6013                murmur_artifact::ContainmentClass::Scoped
6014            ),
6015            "landlock+seccomp"
6016        );
6017        assert_eq!(
6018            crate::containment::applied_mechanism(
6019                &sealed_host,
6020                murmur_artifact::ContainmentClass::Sealed
6021            ),
6022            "mountns+pivot_root+landlock+seccomp"
6023        );
6024    }
6025
6026    /// A probe that learned nothing: no Landlock answer and a namespace probe that could not run.
6027    /// The reading a host gives when neither half of the probe could run.
6028    fn unprobed() -> HostProbe {
6029        HostProbe::from_parts(None, sealed_incapable())
6030    }
6031
6032    /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
6033    /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
6034    /// tier is decided from a reading.
6035    #[test]
6036    fn an_unprobed_host_resolves_to_seccomp_only() {
6037        let tier = unprobed().tier();
6038        if cfg!(target_os = "linux") {
6039            assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
6040        } else {
6041            assert_eq!(tier, EnforcementTier::EnvironmentOnly);
6042        }
6043        assert_ne!(tier, EnforcementTier::KernelSealed);
6044        assert_ne!(tier, EnforcementTier::KernelFull);
6045    }
6046
6047    /// A reading that demonstrated nothing must still name what is missing: a refusal with no
6048    /// blocker is a refusal an operator cannot act on.
6049    #[test]
6050    fn an_unprobed_host_names_a_blocker() {
6051        assert!(unprobed().sealed_shortfall().is_some());
6052    }
6053
6054    /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
6055    /// so nothing downstream can read a failed probe as a met floor.
6056    #[test]
6057    fn an_unprobed_host_achieves_less_than_sealed() {
6058        let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
6059        assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
6060    }
6061
6062    /// The whole point of the direction: a capsule declaring `sealed` against a reading that
6063    /// demonstrated nothing is refused, with the shortfall naming the mechanism.
6064    #[test]
6065    fn an_unprobed_host_refuses_a_declared_sealed_floor() {
6066        let probe = unprobed();
6067        let achieved = crate::containment::achieved_class_for_tier(probe.tier());
6068        let error = crate::containment::check_containment_floor(
6069            murmur_artifact::ContainmentClass::Sealed,
6070            achieved,
6071            probe.sealed_shortfall().as_ref(),
6072            false,
6073            None,
6074        )
6075        .expect_err("an unprobed host cannot back a declared sealed floor");
6076        assert!(matches!(
6077            error,
6078            crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
6079        ));
6080    }
6081
6082    /// The probe succeeding while the AppArmor setting reads `1`: all three attempts created,
6083    /// `mur` unconfined or confined by a container's profile. The host backs `sealed`, maps
6084    /// `/tmp`, and is refused nothing.
6085    #[test]
6086    fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
6087        for attachment in [
6088            crate::sealed::ProfileAttachment::Unconfined,
6089            crate::sealed::ProfileAttachment::Other {
6090                profile: "docker-default".to_string(),
6091            },
6092        ] {
6093            let probe = HostProbe::from_parts(
6094                Some(true),
6095                crate::namespace_probe::NamespaceReading {
6096                    attachment,
6097                    ..sealed_capable()
6098                },
6099            );
6100            if !cfg!(target_os = "linux") {
6101                assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
6102                continue;
6103            }
6104            assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
6105            assert_eq!(probe.sealed_shortfall(), None);
6106            assert!(probe.mount_namespace_usable());
6107            assert_eq!(probe.egress_namespace_failure(), None);
6108            assert_eq!(
6109                crate::temp_dir::temp_mapping(
6110                    EnforcementTier::KernelFull,
6111                    probe.mount_namespace_usable(),
6112                    Path::new("/home/u/w"),
6113                ),
6114                crate::temp_dir::TempMapping::MountNamespace
6115            );
6116        }
6117    }
6118
6119    /// No decision reads the grant: over every reading of the restriction, the tier, the
6120    /// shortfall, the `/tmp` question and the network failure are the same — for a probe that
6121    /// created everything, and for one that created only the network and mount namespaces.
6122    #[test]
6123    fn every_decision_is_identical_across_every_userns_grant() {
6124        use crate::namespace_probe::{
6125            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6126        };
6127        let root_refused = NamespaceReading {
6128            sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6129                step: MountStep::PivotRoot,
6130                errno: libc::EPERM,
6131            }),
6132            ..sealed_capable()
6133        };
6134        for base in [sealed_capable(), root_refused, sealed_incapable()] {
6135            let decide = |grant: crate::sealed::UsernsGrant| {
6136                let probe = HostProbe::from_parts(
6137                    Some(true),
6138                    NamespaceReading {
6139                        userns_grant: grant,
6140                        ..base.clone()
6141                    },
6142                );
6143                (
6144                    probe.tier(),
6145                    probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
6146                    probe.mount_namespace_usable(),
6147                    probe
6148                        .egress_namespace_failure()
6149                        .map(|failure| failure.blocker),
6150                )
6151            };
6152            let expected = decide(crate::sealed::UsernsGrant::Withheld);
6153            for grant in crate::sealed::UsernsGrant::ALL {
6154                assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
6155            }
6156        }
6157    }
6158
6159    /// On whatever host this runs, one live reading is `KernelSealed` exactly when it names no
6160    /// `sealed` shortfall, maps `/tmp` exactly when the network and mount attempts created their
6161    /// namespaces, and reports `E-CAP-005` exactly when the network attempt failed.
6162    #[test]
6163    fn a_live_reading_answers_every_question_from_its_own_attempts() {
6164        let probe = HostProbe::probe();
6165        let namespaces = probe.namespaces().clone();
6166        let linux = cfg!(target_os = "linux");
6167        assert_eq!(
6168            probe.tier() == EnforcementTier::KernelSealed,
6169            probe.sealed_shortfall().is_none(),
6170            "{namespaces:?}"
6171        );
6172        assert_eq!(
6173            probe.mount_namespace_usable(),
6174            linux
6175                && namespaces.network.is_ok()
6176                && namespaces.mount == crate::namespace_probe::MountAttempt::Created
6177        );
6178        assert_eq!(
6179            probe.egress_namespace_failure().is_some(),
6180            linux && namespaces.network.is_err()
6181        );
6182    }
6183
6184    #[test]
6185    fn tier_from_probe_non_linux_is_always_environment_only() {
6186        for landlock in [None, Some(true), Some(false)] {
6187            for namespaces in [sealed_capable(), sealed_incapable()] {
6188                assert_eq!(
6189                    tier_from_probe(false, landlock, &namespaces),
6190                    EnforcementTier::EnvironmentOnly
6191                );
6192            }
6193        }
6194    }
6195
6196    #[test]
6197    fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
6198        assert_eq!(
6199            tier_from_probe(true, Some(true), &sealed_incapable()),
6200            EnforcementTier::KernelFull
6201        );
6202    }
6203
6204    #[test]
6205    fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
6206        assert_eq!(
6207            tier_from_probe(true, Some(false), &sealed_incapable()),
6208            EnforcementTier::KernelSeccompOnly
6209        );
6210    }
6211
6212    #[test]
6213    fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
6214        assert_eq!(
6215            tier_from_probe(true, None, &sealed_incapable()),
6216            EnforcementTier::KernelSeccompOnly
6217        );
6218    }
6219
6220    /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a fully
6221    /// enforced Landlock ruleset and a sealed-root rehearsal that built the composed root — and
6222    /// drops to exactly the tier the missing element still supports, never further.
6223    #[test]
6224    fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
6225        use crate::namespace_probe::{
6226            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6227        };
6228
6229        assert_eq!(
6230            tier_from_probe(true, Some(true), &sealed_capable()),
6231            EnforcementTier::KernelSealed
6232        );
6233
6234        // A refused network namespace attempts nothing after it: `scoped`, not `advisory`.
6235        let network_refused = NamespaceReading {
6236            network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6237                step: crate::network_namespace::CreationStep::IdentityMaps,
6238                errno: libc::EPERM,
6239            }),
6240            mount: MountAttempt::NotAttempted,
6241            sealed_root: MountAttempt::NotAttempted,
6242            ..sealed_capable()
6243        };
6244        assert_eq!(
6245            tier_from_probe(true, Some(true), &network_refused),
6246            EnforcementTier::KernelFull
6247        );
6248
6249        // Every way the sealed-root rehearsal can fail falls back to KernelFull, never below.
6250        for step in [
6251            MountStep::JoinSession,
6252            MountStep::UnshareUserMount,
6253            MountStep::IdentityMaps,
6254            MountStep::MakePrivateNested,
6255            MountStep::Tmpfs,
6256            MountStep::PivotPrepare,
6257            MountStep::PivotRoot,
6258            MountStep::DetachOldRoot,
6259        ] {
6260            let reading = NamespaceReading {
6261                sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6262                    step,
6263                    errno: libc::EPERM,
6264                }),
6265                ..sealed_capable()
6266            };
6267            assert_eq!(
6268                tier_from_probe(true, Some(true), &reading),
6269                EnforcementTier::KernelFull,
6270                "a sealed root refused at {step:?} must fall back to KernelFull"
6271            );
6272        }
6273
6274        // The `/tmp` attempt is not the composed root's: refusing it leaves `sealed` reachable.
6275        let tmp_refused = NamespaceReading {
6276            mount: MountAttempt::Failed(MountAttemptError::Step {
6277                step: MountStep::MakePrivate,
6278                errno: libc::EACCES,
6279            }),
6280            ..sealed_capable()
6281        };
6282        assert_eq!(
6283            tier_from_probe(true, Some(true), &tmp_refused),
6284            EnforcementTier::KernelSealed
6285        );
6286
6287        // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
6288        // cannot reach sealed no matter how good its namespaces are.
6289        assert_eq!(
6290            tier_from_probe(true, Some(false), &sealed_capable()),
6291            EnforcementTier::KernelSeccompOnly
6292        );
6293        assert_eq!(
6294            tier_from_probe(true, None, &sealed_capable()),
6295            EnforcementTier::KernelSeccompOnly
6296        );
6297
6298        // Not Linux at all.
6299        assert_eq!(
6300            tier_from_probe(false, Some(true), &sealed_capable()),
6301            EnforcementTier::EnvironmentOnly
6302        );
6303    }
6304
6305    /// `/tmp` follows the probe's mount attempt: refused at `MS_REC|MS_PRIVATE` it is not mapped;
6306    /// created with the sealed root refused it is mapped at `KernelFull`; with the network
6307    /// namespace refused there is no mount namespace to map it in.
6308    #[test]
6309    fn the_tmp_mapping_follows_the_mount_attempt() {
6310        use crate::namespace_probe::{
6311            MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6312        };
6313        use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
6314        if !cfg!(target_os = "linux") {
6315            return;
6316        }
6317        let workdir = Path::new("/home/u/w");
6318        let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
6319
6320        let mount_refused = HostProbe::from_parts(
6321            Some(true),
6322            NamespaceReading {
6323                mount: refused(MountStep::MakePrivate, libc::EACCES),
6324                sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
6325                ..sealed_capable()
6326            },
6327        );
6328        assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
6329        assert_eq!(
6330            temp_mapping(
6331                mount_refused.tier(),
6332                mount_refused.mount_namespace_usable(),
6333                workdir
6334            ),
6335            TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
6336        );
6337
6338        let root_refused = HostProbe::from_parts(
6339            Some(true),
6340            NamespaceReading {
6341                sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
6342                ..sealed_capable()
6343            },
6344        );
6345        assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
6346        assert_eq!(
6347            temp_mapping(
6348                root_refused.tier(),
6349                root_refused.mount_namespace_usable(),
6350                workdir
6351            ),
6352            TempMapping::MountNamespace
6353        );
6354
6355        let network_refused = HostProbe::from_parts(
6356            Some(true),
6357            NamespaceReading {
6358                network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6359                    step: crate::network_namespace::CreationStep::Unshare,
6360                    errno: libc::EPERM,
6361                }),
6362                mount: MountAttempt::NotAttempted,
6363                sealed_root: MountAttempt::NotAttempted,
6364                ..sealed_capable()
6365            },
6366        );
6367        assert!(!network_refused.mount_namespace_usable());
6368        assert!(matches!(
6369            temp_mapping(
6370                network_refused.tier(),
6371                network_refused.mount_namespace_usable(),
6372                workdir
6373            ),
6374            TempMapping::NotMapped(_)
6375        ));
6376        assert!(network_refused.egress_namespace_failure().is_some());
6377    }
6378
6379    /// The ruleset ABI is the kernel's, capped at v3, on the two Landlock tiers only.
6380    #[test]
6381    fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
6382        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6383            for (kernel, built) in [
6384                (Some(1), Some(1)),
6385                (Some(2), Some(2)),
6386                (Some(3), Some(3)),
6387                (Some(8), Some(3)),
6388                (Some(u8::MAX), Some(3)),
6389            ] {
6390                assert_eq!(
6391                    ruleset_landlock_abi(kernel, tier),
6392                    built,
6393                    "kernel ABI {kernel:?} on {tier:?}"
6394                );
6395            }
6396        }
6397    }
6398
6399    /// No Landlock on the kernel, or a tier that installs no Landlock domain, builds at no ABI.
6400    #[test]
6401    fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
6402        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6403            assert_eq!(ruleset_landlock_abi(None, tier), None);
6404            assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
6405        }
6406        for tier in [
6407            EnforcementTier::KernelSeccompOnly,
6408            EnforcementTier::EnvironmentOnly,
6409        ] {
6410            for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
6411                assert_eq!(
6412                    ruleset_landlock_abi(kernel, tier),
6413                    None,
6414                    "kernel ABI {kernel:?} on {tier:?}"
6415                );
6416            }
6417        }
6418    }
6419
6420    /// `ShellEnforcement::resolve` carries the ruleset ABI of the probe it is handed, keyed on the
6421    /// applied tier, and an all-negative probe carries none.
6422    #[test]
6423    fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
6424        let negative = unprobed();
6425        for abi in [None, Some(1), Some(8)] {
6426            let enforcement = ShellEnforcement::resolve_declared(
6427                &CapabilityPolicy::default(),
6428                murmur_artifact::ContainmentClass::Advisory,
6429                negative.clone().with_landlock_kernel_abi(abi),
6430            )
6431            .unwrap()
6432            .with_test_session_dirs();
6433            assert_eq!(
6434                enforcement.landlock_abi,
6435                ruleset_landlock_abi(abi, enforcement.tier),
6436                "kernel ABI {abi:?}"
6437            );
6438            // An all-negative probe resolves below the Landlock tiers on every OS.
6439            assert_eq!(enforcement.landlock_abi, None);
6440        }
6441        if cfg!(target_os = "linux") {
6442            let full = HostProbe::from_parts(Some(true), sealed_incapable());
6443            let enforcement = ShellEnforcement::resolve_declared(
6444                &CapabilityPolicy::default(),
6445                murmur_artifact::ContainmentClass::Scoped,
6446                full.with_landlock_kernel_abi(Some(8)),
6447            )
6448            .unwrap()
6449            .with_test_session_dirs();
6450            assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
6451            assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
6452        }
6453    }
6454
6455    /// The `mur doctor` Landlock block for each kernel ABI that changes what it says.
6456    #[test]
6457    fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
6458        if !cfg!(target_os = "linux") {
6459            for abi in [None, Some(1), Some(8)] {
6460                assert_eq!(
6461                    landlock_doctor_lines(abi),
6462                    vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
6463                );
6464            }
6465            return;
6466        }
6467
6468        let none = landlock_doctor_lines(None).join("\n");
6469        assert!(none.contains("no Landlock on this host"), "{none}");
6470        assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
6471
6472        let v1 = landlock_doctor_lines(Some(1)).join("\n");
6473        assert!(v1.contains("kernel ABI: 1"), "{v1}");
6474        assert!(v1.contains("ruleset ABI: 1"), "{v1}");
6475        assert!(v1.contains("EXDEV"), "{v1}");
6476        assert!(v1.contains("Invalid cross-device link"), "{v1}");
6477        assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
6478        assert!(v1.contains("uv"), "{v1}");
6479
6480        let v2 = landlock_doctor_lines(Some(2)).join("\n");
6481        assert!(v2.contains("kernel ABI: 2"), "{v2}");
6482        assert!(v2.contains("ruleset ABI: 2"), "{v2}");
6483        assert!(!v2.contains("EXDEV"), "{v2}");
6484        assert!(
6485            v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
6486            "{v2}"
6487        );
6488
6489        for kernel in [3, 8] {
6490            let lines = landlock_doctor_lines(Some(kernel));
6491            assert_eq!(
6492                lines,
6493                vec![
6494                    format!("kernel ABI: {kernel}"),
6495                    "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
6496                ]
6497            );
6498        }
6499    }
6500
6501    /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
6502    /// could give it more. Silently installing a composed root under a `scoped` declaration would
6503    /// delete host paths its `interpreter_runtime` grants legitimately name.
6504    #[test]
6505    fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
6506        use murmur_artifact::ContainmentClass;
6507
6508        assert_eq!(
6509            applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
6510            EnforcementTier::KernelSealed
6511        );
6512        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
6513            assert_eq!(
6514                applied_tier(EnforcementTier::KernelSealed, declared),
6515                EnforcementTier::KernelFull,
6516                "declaring {declared} must not opt a capsule into the composed root"
6517            );
6518        }
6519
6520        // Every other tier is passed through untouched, whatever was declared — the declared floor
6521        // can only ever weaken the applied mechanism, never strengthen it past the host.
6522        for host_tier in [
6523            EnforcementTier::KernelFull,
6524            EnforcementTier::KernelSeccompOnly,
6525            EnforcementTier::EnvironmentOnly,
6526        ] {
6527            for declared in [
6528                ContainmentClass::Advisory,
6529                ContainmentClass::Scoped,
6530                ContainmentClass::Sealed,
6531            ] {
6532                assert_eq!(applied_tier(host_tier, declared), host_tier);
6533            }
6534        }
6535    }
6536
6537    #[test]
6538    fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
6539        use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
6540
6541        let policy = CapabilityPolicy {
6542            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6543                binary: "python3".to_string(),
6544                dirs: vec![
6545                    // Already inside /usr, which the fixed list binds wholesale.
6546                    InterpreterRuntimeDir {
6547                        path: "/usr/lib/python3.11".to_string(),
6548                        list_dir: true,
6549                    },
6550                    InterpreterRuntimeDir {
6551                        path: "/opt/py/lib".to_string(),
6552                        list_dir: true,
6553                    },
6554                    // A duplicate, and a relative path that names nothing absolute.
6555                    InterpreterRuntimeDir {
6556                        path: "/opt/py/lib".to_string(),
6557                        list_dir: false,
6558                    },
6559                    InterpreterRuntimeDir {
6560                        path: "relative/lib".to_string(),
6561                        list_dir: false,
6562                    },
6563                ],
6564            }],
6565            ..CapabilityPolicy::default()
6566        };
6567        let exec_allow = vec![
6568            PathBuf::from("/usr/bin/python3"),
6569            PathBuf::from("/opt/toolchain/bin/cc"),
6570        ];
6571
6572        assert_eq!(
6573            resolve_sealed_bind_dirs(&exec_allow, &policy),
6574            vec![
6575                PathBuf::from("/opt/toolchain/bin"),
6576                PathBuf::from("/opt/py/lib")
6577            ]
6578        );
6579    }
6580
6581    /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
6582    /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
6583    /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
6584    /// relative path — which cannot be re-based under the composed root at all — is skipped.
6585    #[test]
6586    fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
6587        use murmur_artifact::StagedRuntimeGrant;
6588
6589        let grant = |binary: &str, source: &str| StagedRuntimeGrant {
6590            binary: binary.to_string(),
6591            source_path: source.to_string(),
6592            pin: "pin-1".to_string(),
6593        };
6594        let policy = CapabilityPolicy {
6595            shell_staged_runtime: vec![
6596                grant("python3", "/opt/py"),
6597                // Inside /usr, which the fixed list already binds. Kept anyway — unlike
6598                // `resolve_sealed_bind_dirs`, which drops exactly this case.
6599                grant("perl", "/usr/lib/perl5"),
6600                // A duplicate of the first, and a relative path naming nothing absolute.
6601                grant("python3.12", "/opt/py"),
6602                grant("ruby", "relative/ruby"),
6603            ],
6604            ..CapabilityPolicy::default()
6605        };
6606
6607        assert_eq!(
6608            resolve_staged_runtime_dirs(&policy),
6609            vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
6610        );
6611    }
6612
6613    /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
6614    /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
6615    /// must therefore cover the same directories.
6616    #[test]
6617    fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
6618        use murmur_artifact::StagedRuntimeGrant;
6619
6620        let policy = CapabilityPolicy {
6621            shell_staged_runtime: vec![StagedRuntimeGrant {
6622                binary: "python3".to_string(),
6623                source_path: "/opt/py".to_string(),
6624                pin: "pin-1".to_string(),
6625            }],
6626            ..CapabilityPolicy::default()
6627        };
6628
6629        let dirs = resolve_staged_runtime_dirs(&policy);
6630        let grants = resolve_staged_runtime_landlock_grants(&policy);
6631
6632        assert_eq!(
6633            grants,
6634            vec![LandlockGrant {
6635                path: PathBuf::from("/opt/py"),
6636                list_dir: true,
6637                executable: true,
6638                pinned: None,
6639            }],
6640            "a staged tree is walked by the runtime that uses it, so it must be listable",
6641        );
6642        assert_eq!(
6643            grants
6644                .iter()
6645                .map(|grant| grant.path.clone())
6646                .collect::<Vec<_>>(),
6647            dirs,
6648            "the bound set and the granted set must not drift apart",
6649        );
6650    }
6651
6652    #[test]
6653    fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
6654        let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
6655
6656        assert_eq!(
6657            grants,
6658            crate::sealed::SEALED_RUNTIME_PATHS
6659                .iter()
6660                .map(|path| LandlockGrant {
6661                    path: PathBuf::from(path),
6662                    list_dir: true,
6663                    executable: false,
6664                    pinned: None,
6665                })
6666                .collect::<Vec<_>>(),
6667            "the composed root binds exactly these, so exactly these must be enumerable — \
6668             the bound set and the granted set must not drift apart",
6669        );
6670        assert!(
6671            grants.iter().all(|grant| grant.list_dir),
6672            "a runtime tree an interpreter walks is useless without getdents64",
6673        );
6674        assert!(
6675            grants.iter().all(|grant| !grant.executable),
6676            "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
6677             sealed capsule run every binary the host ships, whatever shell.allow says",
6678        );
6679    }
6680
6681    #[test]
6682    fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
6683        for tier in [
6684            EnforcementTier::KernelFull,
6685            EnforcementTier::KernelSeccompOnly,
6686            EnforcementTier::EnvironmentOnly,
6687        ] {
6688            assert!(
6689                resolve_sealed_runtime_landlock_grants(tier).is_empty(),
6690                "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
6691                 would enumerate the real host filesystem",
6692            );
6693        }
6694    }
6695
6696    /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
6697    /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
6698    /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
6699    /// is the only honest thing a test can do about a kernel capability it may not have.
6700    #[test]
6701    fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
6702        let policy = CapabilityPolicy::default();
6703        let usr = PathBuf::from("/usr");
6704
6705        let scoped = ShellEnforcement::resolve_declared(
6706            &policy,
6707            murmur_artifact::ContainmentClass::Scoped,
6708            HostProbe::probe(),
6709        )
6710        .expect("an empty policy resolves")
6711        .with_test_session_dirs();
6712        assert!(
6713            !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
6714            "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
6715             unenumerable there",
6716        );
6717
6718        let sealed = ShellEnforcement::resolve_declared(
6719            &policy,
6720            murmur_artifact::ContainmentClass::Sealed,
6721            HostProbe::probe(),
6722        )
6723        .expect("an empty policy resolves")
6724        .with_test_session_dirs();
6725        let granted: Vec<&LandlockGrant> = sealed
6726            .landlock_grants
6727            .iter()
6728            .filter(|grant| {
6729                crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
6730            })
6731            .collect();
6732        if sealed.tier == EnforcementTier::KernelSealed {
6733            assert_eq!(
6734                granted.len(),
6735                crate::sealed::SEALED_RUNTIME_PATHS.len(),
6736                "a sealed session must carry one listable grant per bound runtime path",
6737            );
6738            assert!(granted
6739                .iter()
6740                .all(|grant| grant.list_dir && !grant.executable));
6741        } else {
6742            assert!(
6743                granted.is_empty(),
6744                "this host fell back to {:?}, where there is no composed root to enumerate",
6745                sealed.tier,
6746            );
6747        }
6748    }
6749
6750    #[test]
6751    fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
6752        let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
6753
6754        assert_eq!(
6755            grants,
6756            crate::sealed::SEALED_ETC_PATHS
6757                .iter()
6758                .map(|entry| LandlockGrant {
6759                    path: PathBuf::from(entry.path),
6760                    list_dir: entry.directory,
6761                    executable: false,
6762                    pinned: None,
6763                })
6764                .collect::<Vec<_>>(),
6765            "the composed root binds exactly these /etc entries, so exactly these must be \
6766             readable — the bound set and the granted set must not drift apart",
6767        );
6768        assert!(
6769            grants.iter().all(|grant| !grant.executable),
6770            "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
6771             run — /etc/alternatives is a directory of symlinks into /usr/bin",
6772        );
6773
6774        // The `directory` classification itself, spelled out independently of the constant so a
6775        // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
6776        // regular file) shows up as a test failure rather than as a TLS bug on a real host.
6777        let listable: Vec<&str> = grants
6778            .iter()
6779            .filter(|grant| grant.list_dir)
6780            .map(|grant| grant.path.to_str().unwrap())
6781            .collect();
6782        assert_eq!(
6783            listable,
6784            vec![
6785                "/etc/ld.so.conf.d",
6786                "/etc/alternatives",
6787                "/etc/ssl",
6788                "/etc/pki",
6789                "/etc/ca-certificates",
6790                "/etc/terminfo",
6791            ],
6792            "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
6793             the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
6794        );
6795    }
6796
6797    #[test]
6798    fn sealed_etc_landlock_grants_are_sealed_tier_only() {
6799        for tier in [
6800            EnforcementTier::KernelFull,
6801            EnforcementTier::KernelSeccompOnly,
6802            EnforcementTier::EnvironmentOnly,
6803        ] {
6804            assert!(
6805                resolve_sealed_etc_landlock_grants(tier).is_empty(),
6806                "{tier:?} has no composed root, so these rules would hand out the real host's \
6807                 /etc — its trust store, resolv.conf and account databases",
6808            );
6809        }
6810    }
6811
6812    /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
6813    /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
6814    /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
6815    /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
6816    #[test]
6817    fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
6818        let policy = CapabilityPolicy::default();
6819        let is_etc_grant = |grant: &LandlockGrant| {
6820            crate::sealed::SEALED_ETC_PATHS
6821                .iter()
6822                .any(|entry| Path::new(entry.path) == grant.path)
6823        };
6824
6825        let scoped = ShellEnforcement::resolve_declared(
6826            &policy,
6827            murmur_artifact::ContainmentClass::Scoped,
6828            HostProbe::probe(),
6829        )
6830        .expect("an empty policy resolves")
6831        .with_test_session_dirs();
6832        assert!(
6833            !scoped.landlock_grants.iter().any(is_etc_grant),
6834            "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
6835             neighbours must not become readable there",
6836        );
6837
6838        let sealed = ShellEnforcement::resolve_declared(
6839            &policy,
6840            murmur_artifact::ContainmentClass::Sealed,
6841            HostProbe::probe(),
6842        )
6843        .expect("an empty policy resolves")
6844        .with_test_session_dirs();
6845        let granted: Vec<&LandlockGrant> = sealed
6846            .landlock_grants
6847            .iter()
6848            .filter(|grant| is_etc_grant(grant))
6849            .collect();
6850        if sealed.tier == EnforcementTier::KernelSealed {
6851            assert_eq!(
6852                granted.len(),
6853                crate::sealed::SEALED_ETC_PATHS.len(),
6854                "a sealed session must carry one read grant per bound /etc entry",
6855            );
6856            assert!(granted.iter().all(|grant| !grant.executable));
6857        } else {
6858            assert!(
6859                granted.is_empty(),
6860                "this host fell back to {:?}, where there is no composed root and /etc is the \
6861                 host's own",
6862                sealed.tier,
6863            );
6864        }
6865    }
6866
6867    #[test]
6868    fn resolve_network_allowlist_ips_empty_list_is_empty() {
6869        let ips = resolve_network_allowlist_ips(&[]).unwrap();
6870        assert!(ips.is_empty());
6871    }
6872
6873    #[test]
6874    fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
6875        let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
6876        assert!(
6877            !ips.is_empty(),
6878            "localhost should resolve to at least one address"
6879        );
6880        assert!(
6881            ips.iter().all(|ip| ip.is_loopback()),
6882            "every resolved localhost address should be loopback, got {ips:?}"
6883        );
6884    }
6885
6886    #[test]
6887    fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
6888        let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
6889        assert!(!error.is_empty());
6890    }
6891
6892    /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
6893    /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
6894    /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
6895    /// to resolve, which keeps this test independent of the host's DNS.
6896    #[test]
6897    fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
6898        let ips =
6899            resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
6900                .expect("an unresolvable host must not be a hard error");
6901        assert!(
6902            ips.is_empty(),
6903            "an unresolvable host should contribute no IPs, got {ips:?}"
6904        );
6905    }
6906
6907    /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
6908    /// loop continues rather than aborting on the first miss.
6909    #[test]
6910    fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
6911        let ips = resolve_network_allowlist_ips(&[
6912            "definitely-not-a-real-host.invalid".to_string(),
6913            "localhost".to_string(),
6914        ])
6915        .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
6916
6917        assert!(
6918            !ips.is_empty(),
6919            "localhost's addresses should survive alongside an unresolvable host"
6920        );
6921        assert!(
6922            ips.iter().all(|ip| ip.is_loopback()),
6923            "only localhost should have contributed addresses, got {ips:?}"
6924        );
6925    }
6926
6927    #[test]
6928    fn resolve_network_allowlist_ips_dedupes_across_hosts() {
6929        // Two entries that both resolve to loopback should not produce duplicate IPs beyond
6930        // what's actually distinct.
6931        let ips =
6932            resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
6933                .unwrap();
6934        let mut sorted = ips.clone();
6935        sorted.sort();
6936        sorted.dedup();
6937        assert_eq!(
6938            ips.len(),
6939            sorted.len(),
6940            "resolved IPs should already be deduplicated"
6941        );
6942    }
6943
6944    #[test]
6945    fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
6946        let enforcement = ShellEnforcement::environment_only();
6947        assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
6948        assert!(enforcement.network_allow_ips.is_empty());
6949    }
6950
6951    /// With no scope a spawn's `RLIMIT_NPROC` is headroom over a real count. A `0` baseline
6952    /// would turn `max_processes` into an absolute per-uid ceiling that an interactive account
6953    /// is already past, failing the subprocess's first `fork()`.
6954    #[cfg(any(target_os = "linux", target_os = "macos"))]
6955    #[test]
6956    fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
6957        match ShellEnforcement::environment_only()
6958            .with_test_session_dirs()
6959            .nproc_bound()
6960        {
6961            crate::resources::NprocBound::Headroom { baseline } => assert!(
6962                baseline > 0,
6963                "the test process is owned by this uid, so the count cannot be zero"
6964            ),
6965            other => panic!("no scope must give headroom, got {other:?}"),
6966        }
6967    }
6968
6969    /// The baseline is taken on each call, not when the enforcement was built: threads the host
6970    /// starts in between are counted by the next spawn.
6971    #[cfg(target_os = "linux")]
6972    #[test]
6973    fn nproc_bound_without_a_scope_is_measured_on_each_call() {
6974        let _lock = crate::resources::test_support::host_threads_lock();
6975        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
6976        let baseline = |bound| match bound {
6977            crate::resources::NprocBound::Headroom { baseline } => baseline,
6978            other => panic!("no scope must give headroom, got {other:?}"),
6979        };
6980
6981        let first = baseline(enforcement.nproc_bound());
6982        let holders = crate::resources::test_support::HeldThreads::start(200);
6983        let second = baseline(enforcement.nproc_bound());
6984        drop(holders);
6985
6986        assert!(
6987            second > first,
6988            "200 threads started between the calls must raise the baseline: {first} then {second}"
6989        );
6990    }
6991
6992    /// A spawn that will join a live scope leaves `RLIMIT_NPROC` to `pids.max`.
6993    #[cfg(target_os = "linux")]
6994    #[test]
6995    fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
6996        if !crate::cgroup::cgroup_delegation_available() {
6997            crate::runtime_err!(
6998                "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
6999                 cannot delegate a cgroup v2 scope"
7000            );
7001            return;
7002        }
7003        let temp = tempfile::tempdir().unwrap();
7004        let limits = crate::resources::HostResourceLimits::default();
7005        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
7006            .expect("this host delegates a cgroup scope")
7007            .scope;
7008        let enforcement = ShellEnforcement::environment_only()
7009            .with_test_session_dirs()
7010            .with_host_bounding(scope, None);
7011
7012        assert_eq!(
7013            enforcement.nproc_bound(),
7014            crate::resources::NprocBound::CgroupPidsMax
7015        );
7016    }
7017
7018    /// A Linux capsule with a cgroup scope gets no `RLIMIT_NPROC` from the runtime, so threads the
7019    /// rest of the host starts after the enforcement was resolved cannot spend its fork headroom.
7020    /// The 400 holders put the uid well past any `RLIMIT_NPROC` derived from a count taken before
7021    /// they started, which fails the first `fork()` in the subprocess with `EAGAIN`. The 40
7022    /// concurrent `sleep`s and the concurrent `node` children each need headroom well past the
7023    /// declared `max_processes` of 32.
7024    #[cfg(target_os = "linux")]
7025    #[test]
7026    fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
7027        const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
7028        if !crate::cgroup::cgroup_delegation_available() {
7029            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
7030            return;
7031        }
7032        let _lock = crate::resources::test_support::host_threads_lock();
7033
7034        let temp = tempfile::tempdir().unwrap();
7035        let limits = crate::resources::HostResourceLimits {
7036            max_processes: 32,
7037            max_open_files: 256,
7038            ..crate::resources::HostResourceLimits::default()
7039        };
7040        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
7041            .expect("this host delegates a cgroup scope")
7042            .scope;
7043        assert!(
7044            scope.is_some(),
7045            "a required scope on Linux is always created"
7046        );
7047        let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
7048        let mut shell_allow = vec!["bash".to_string()];
7049        if node_available {
7050            shell_allow.push("node".to_string());
7051        }
7052        let policy = CapabilityPolicy {
7053            shell_allow,
7054            resources: limits,
7055            ..CapabilityPolicy::default()
7056        };
7057        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7058        enforcement.resource_limits = limits;
7059        let enforcement = enforcement.with_host_bounding(scope, None);
7060
7061        let holders = crate::resources::test_support::HeldThreads::start(400);
7062
7063        let result = crate::shell::execute_shell(
7064            "bash",
7065            &[
7066                "-c",
7067                "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
7068            ],
7069            &[],
7070            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7071            &policy,
7072            &enforcement,
7073        )
7074        .expect("bash must run");
7075        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7076        assert_eq!(result.resource_limit_hit, None);
7077        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7078        let lines: Vec<&str> = result.stdout.lines().collect();
7079        assert_eq!(
7080            lines,
7081            [
7082                crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
7083                "256"
7084            ],
7085            "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
7086        );
7087
7088        if node_available {
7089            let script = "const { spawn } = require('child_process');\n\
7090                const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
7091                  const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
7092                  child.once('error', (error) => resolve(error.message));\n\
7093                  child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
7094                }));\n\
7095                Promise.all(runs).then((outcomes) => {\n\
7096                  const failures = outcomes.filter((outcome) => outcome !== null);\n\
7097                  if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
7098                });\n";
7099            let result = crate::shell::execute_shell(
7100                "node",
7101                &["-e", script],
7102                &[],
7103                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7104                &policy,
7105                &enforcement,
7106            )
7107            .expect("node must run");
7108            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7109        } else {
7110            crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
7111        }
7112
7113        drop(holders);
7114    }
7115
7116    /// Without a scope `RLIMIT_NPROC` is the only process bound, so its baseline has to follow the
7117    /// host: 300 threads started after the enforcement was resolved raise the next spawn's
7118    /// ceiling, and releasing them lowers the one after.
7119    #[cfg(target_os = "linux")]
7120    #[test]
7121    fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
7122        const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
7123        let _lock = crate::resources::test_support::host_threads_lock();
7124
7125        let temp = tempfile::tempdir().unwrap();
7126        let policy = CapabilityPolicy {
7127            shell_allow: vec!["bash".to_string()],
7128            resources: crate::resources::HostResourceLimits {
7129                max_processes: 64,
7130                ..crate::resources::HostResourceLimits::default()
7131            },
7132            ..CapabilityPolicy::default()
7133        };
7134        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7135        enforcement.resource_limits = policy.resources;
7136
7137        let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
7138        let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7139        if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
7140            crate::runtime_err!(
7141                "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
7142                 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
7143            );
7144            return;
7145        }
7146
7147        let hard_nproc = || -> u64 {
7148            let result = crate::shell::execute_shell(
7149                "bash",
7150                &["-c", "ulimit -Hu"],
7151                &[],
7152                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7153                &policy,
7154                &enforcement,
7155            )
7156            .expect("bash must run");
7157            assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7158            result.stdout.trim().parse().expect("a finite ceiling")
7159        };
7160
7161        let holders = crate::resources::test_support::HeldThreads::start(300);
7162        let held = hard_nproc();
7163        assert!(
7164            held >= before + 64 + 150,
7165            "the ceiling must count the holder threads started after the enforcement was \
7166             resolved: {held} with {before} tasks before them"
7167        );
7168
7169        drop(holders);
7170        let released = hard_nproc();
7171        assert!(
7172            released < held,
7173            "the ceiling must drop once the holders exit: {released} after, {held} while held"
7174        );
7175    }
7176
7177    fn make_executable(path: &Path, contents: &str) {
7178        use std::os::unix::fs::PermissionsExt;
7179        std::fs::write(path, contents).unwrap();
7180        let mut perms = std::fs::metadata(path).unwrap().permissions();
7181        perms.set_mode(0o755);
7182        std::fs::set_permissions(path, perms).unwrap();
7183    }
7184
7185    fn bootstrap_log_contents(workdir: &Path) -> String {
7186        std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
7187    }
7188
7189    // ---- exec allowlist resolution (identity, not names) ----
7190
7191    #[test]
7192    fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
7193        let temp = tempfile::tempdir().unwrap();
7194        let bin_dir = temp.path().join("bin");
7195        std::fs::create_dir(&bin_dir).unwrap();
7196        let tool = bin_dir.join("mytool");
7197        make_executable(&tool, "#!/bin/sh\nexit 0\n");
7198
7199        let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
7200        assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
7201    }
7202
7203    #[test]
7204    fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
7205        let temp = tempfile::tempdir().unwrap();
7206        let resolved = resolve_exec_allowlist_in(
7207            &["definitely-not-a-real-binary".to_string(), String::new()],
7208            &[temp.path().to_path_buf()],
7209        );
7210        assert!(
7211            resolved.is_empty(),
7212            "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
7213        );
7214    }
7215
7216    #[test]
7217    fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
7218        let temp = tempfile::tempdir().unwrap();
7219        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7220        let resolved =
7221            resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
7222        assert!(resolved.is_empty());
7223    }
7224
7225    #[test]
7226    fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
7227        let temp = tempfile::tempdir().unwrap();
7228        let real = temp.path().join("real-tool");
7229        make_executable(&real, "#!/bin/sh\nexit 0\n");
7230        let bin_dir = temp.path().join("bin");
7231        std::fs::create_dir(&bin_dir).unwrap();
7232        std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
7233
7234        let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
7235        assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
7236    }
7237
7238    // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
7239
7240    #[test]
7241    fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
7242        let temp = tempfile::tempdir().unwrap();
7243        let bin_dir = temp.path().join("bin");
7244        std::fs::create_dir(&bin_dir).unwrap();
7245        let tool = bin_dir.join("mytool");
7246        make_executable(&tool, "#!/bin/sh\nexit 0\n");
7247
7248        let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
7249        assert_eq!(
7250            resolved,
7251            std::fs::canonicalize(&tool)
7252                .unwrap()
7253                .to_string_lossy()
7254                .into_owned(),
7255            "a resolvable name must report the canonical absolute path, not the bare name"
7256        );
7257    }
7258
7259    #[test]
7260    fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
7261        let temp = tempfile::tempdir().unwrap();
7262        let path_dirs = [temp.path().to_path_buf()];
7263        let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
7264        assert_eq!(
7265            resolved, "definitely-not-a-real-binary",
7266            "an unresolvable name must fall back to the bare name, never error"
7267        );
7268        assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
7269    }
7270
7271    #[test]
7272    fn resolve_invoked_binary_path_takes_the_first_path_match() {
7273        let temp = tempfile::tempdir().unwrap();
7274        let first = temp.path().join("first");
7275        let second = temp.path().join("second");
7276        std::fs::create_dir(&first).unwrap();
7277        std::fs::create_dir(&second).unwrap();
7278        make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
7279        make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
7280
7281        let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
7282        assert_eq!(
7283            resolved,
7284            std::fs::canonicalize(first.join("mytool"))
7285                .unwrap()
7286                .to_string_lossy()
7287                .into_owned(),
7288            "reporting must match execvp: the first PATH hit is the one that ran"
7289        );
7290    }
7291
7292    #[test]
7293    fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
7294        let temp = tempfile::tempdir().unwrap();
7295        std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7296        assert_eq!(
7297            resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
7298            "mytool"
7299        );
7300    }
7301
7302    #[test]
7303    fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
7304        let temp = tempfile::tempdir().unwrap();
7305        let real = temp.path().join("real-tool");
7306        make_executable(&real, "#!/bin/sh\nexit 0\n");
7307        let link = temp.path().join("link-tool");
7308        std::os::unix::fs::symlink(&real, &link).unwrap();
7309
7310        assert_eq!(
7311            resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
7312            std::fs::canonicalize(&real)
7313                .unwrap()
7314                .to_string_lossy()
7315                .into_owned()
7316        );
7317    }
7318
7319    // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
7320
7321    #[test]
7322    fn parse_elf_extracts_interp_needed_and_runpath() {
7323        let bytes = build_elf64(
7324            Some("/lib64/ld-linux-x86-64.so.2"),
7325            &["libc.so.6", "libm.so.6"],
7326            Some("$ORIGIN/../lib:/opt/custom/lib"),
7327        );
7328        let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
7329        assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
7330        assert_eq!(
7331            deps.needed,
7332            vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
7333        );
7334        assert_eq!(
7335            deps.runpaths,
7336            vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
7337            "DT_RUNPATH must be split on ':' with empty parts dropped"
7338        );
7339    }
7340
7341    #[test]
7342    fn parse_elf_static_binary_contributes_only_itself() {
7343        // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
7344        let bytes = build_elf64(None, &[], None);
7345        let deps =
7346            parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
7347        assert_eq!(deps, ElfDependencies::default());
7348    }
7349
7350    #[test]
7351    fn parse_elf_rejects_non_elf_and_non_elf64() {
7352        assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
7353        // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
7354        let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
7355        elf32[4] = 1;
7356        assert!(
7357            parse_elf_dependencies(elf32.as_slice()).is_none(),
7358            "32-bit ELF is not a binary this runtime execs and must not parse"
7359        );
7360    }
7361
7362    /// Every ELF image of this host's `python3`, `perl`, `bash` and `git` closures and every file
7363    /// in python's `lib-dynload`, and truncated and malformed copies of each binary, parse the same
7364    /// read from memory and read by position from the file, `None` included.
7365    #[cfg(target_os = "linux")]
7366    #[test]
7367    #[allow(clippy::print_stdout)]
7368    fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
7369        let mut binaries = Vec::new();
7370        for name in ["python3", "perl", "bash", "git"] {
7371            match find_on_path(name) {
7372                Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
7373                None => println!("[SKIP] no {name} on PATH"),
7374            }
7375        }
7376        let mut images: std::collections::BTreeSet<PathBuf> =
7377            resolve_landlock_grants(&binaries).into_iter().collect();
7378        let dynload = std::process::Command::new("python3")
7379            .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
7380            .output()
7381            .ok()
7382            .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
7383            .unwrap_or_default();
7384        for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
7385            for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
7386                images.insert(entry.path());
7387            }
7388        }
7389        let compare = |path: &Path| {
7390            let Ok(bytes) = std::fs::read(path) else {
7391                return;
7392            };
7393            let file = ElfFile::open_host(path).unwrap();
7394            assert_eq!(
7395                parse_elf_dependencies(bytes.as_slice()),
7396                parse_elf_dependencies(&file),
7397                "{}",
7398                path.display()
7399            );
7400            assert_eq!(
7401                elf_dynamic_symbol_names(bytes.as_slice()),
7402                elf_dynamic_symbol_names(&file),
7403                "{}",
7404                path.display()
7405            );
7406            assert_eq!(
7407                elf_is_shared_object(bytes.as_slice()),
7408                elf_is_shared_object(&file),
7409                "{}",
7410                path.display()
7411            );
7412        };
7413        for path in &images {
7414            compare(path);
7415        }
7416        println!("compared {} host images", images.len());
7417
7418        // Truncated and malformed copies: cut inside each table the parsers read, and with the
7419        // header's table offsets pointed past the end of the file.
7420        let temp = tempfile::tempdir().unwrap();
7421        let mut variants = 0;
7422        for binary in &binaries {
7423            let bytes = std::fs::read(binary).unwrap();
7424            let mut cuts = vec![
7425                0,
7426                1,
7427                17,
7428                18,
7429                63,
7430                64,
7431                100,
7432                4096,
7433                bytes.len() / 2,
7434                bytes.len() - 1,
7435            ];
7436            for field in [32usize, 40] {
7437                if let Some(offset) = read_u64(&bytes, field, true) {
7438                    cuts.push(offset as usize + 30);
7439                }
7440            }
7441            let mut copies: Vec<Vec<u8>> = cuts
7442                .into_iter()
7443                .filter(|cut| *cut <= bytes.len())
7444                .map(|cut| bytes[..cut].to_vec())
7445                .collect();
7446            for field in [32usize, 40] {
7447                let mut moved = bytes.clone();
7448                moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
7449                copies.push(moved);
7450            }
7451            let mut big_endian = bytes.clone();
7452            big_endian[5] = 2;
7453            copies.push(big_endian);
7454            for copy in copies {
7455                let path = temp.path().join(format!("variant-{variants}"));
7456                std::fs::write(&path, &copy).unwrap();
7457                compare(&path);
7458                variants += 1;
7459            }
7460        }
7461        println!("compared {variants} malformed images");
7462    }
7463
7464    /// Resolving a binary's closure reads its headers and tables, not the whole file: a stand-in
7465    /// padded to 64 MiB costs a few KiB.
7466    #[cfg(target_os = "linux")]
7467    #[test]
7468    #[allow(clippy::print_stdout)]
7469    fn resolving_a_closure_reads_no_binary_in_full() {
7470        let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
7471        let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
7472            return;
7473        };
7474        crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
7475        let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
7476        let before = crate::interpreter_runtime::tests::thread_rchar();
7477        let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
7478        let read = crate::interpreter_runtime::tests::thread_rchar() - before;
7479        assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
7480        assert!(grants.contains(&stand_in));
7481        assert!(
7482            read < 8 * 1024 * 1024,
7483            "resolving the closure read {read} bytes"
7484        );
7485        println!("resolving the padded stand-in's closure read {read} bytes");
7486    }
7487
7488    // ---- soname resolution (loader search-order, synthetic fixtures) ----
7489
7490    #[test]
7491    fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
7492        let temp = tempfile::tempdir().unwrap();
7493        let origin = temp.path().join("app");
7494        std::fs::create_dir(&origin).unwrap();
7495        let libs = temp.path().join("libs");
7496        std::fs::create_dir(&libs).unwrap();
7497        std::fs::write(libs.join("libc.so.6"), "x").unwrap();
7498
7499        // Found in a standard search directory.
7500        assert_eq!(
7501            resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
7502            Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
7503        );
7504        // Unresolvable soname → None (shrink-not-fail).
7505        assert_eq!(
7506            resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
7507            None
7508        );
7509
7510        // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
7511        let origin_lib = origin.join("lib");
7512        std::fs::create_dir(&origin_lib).unwrap();
7513        std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
7514        assert_eq!(
7515            resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
7516            Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
7517        );
7518    }
7519
7520    // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
7521
7522    #[test]
7523    fn resolve_landlock_grants_derives_interp_and_needed_closure() {
7524        let temp = tempfile::tempdir().unwrap();
7525        let bin_dir = temp.path().join("bin");
7526        let lib_dir = temp.path().join("lib");
7527        std::fs::create_dir(&bin_dir).unwrap();
7528        std::fs::create_dir(&lib_dir).unwrap();
7529
7530        // A fake interpreter and a fake shared library, both outside any "workdir".
7531        let interp = bin_dir.join("ld-fake.so");
7532        std::fs::write(&interp, "not really elf").unwrap();
7533        std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
7534
7535        // A synthetic dynamically-linked binary naming that interp + that soname.
7536        let prog = bin_dir.join("prog");
7537        let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
7538        std::fs::write(&prog, &elf).unwrap();
7539
7540        let grants =
7541            resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
7542
7543        let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
7544        assert!(
7545            grants.contains(&expect(prog)),
7546            "the binary itself must be granted: {grants:?}"
7547        );
7548        assert!(
7549            grants.contains(&expect(interp)),
7550            "the ELF interpreter must be granted: {grants:?}"
7551        );
7552        assert!(
7553            grants.contains(&expect(lib_dir.join("libfake.so.1"))),
7554            "each resolved DT_NEEDED library must be granted: {grants:?}"
7555        );
7556    }
7557
7558    #[test]
7559    fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
7560        let temp = tempfile::tempdir().unwrap();
7561        let bin_dir = temp.path().join("bin");
7562        std::fs::create_dir(&bin_dir).unwrap();
7563
7564        // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
7565        let prog = bin_dir.join("prog");
7566        std::fs::write(
7567            &prog,
7568            build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
7569        )
7570        .unwrap();
7571        let missing = bin_dir.join("does-not-exist");
7572
7573        let grants =
7574            resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
7575
7576        // Only the real binary survives; the missing binary, missing interp, and unresolvable
7577        // soname each contribute nothing (shrink-not-fail).
7578        assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
7579    }
7580
7581    // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
7582
7583    fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
7584        InterpreterRuntimeGrant {
7585            binary: binary.to_string(),
7586            dirs: dirs
7587                .iter()
7588                .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
7589                    path: path.to_string(),
7590                    list_dir: *list_dir,
7591                })
7592                .collect(),
7593        }
7594    }
7595
7596    #[test]
7597    fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
7598        // Two directories under the same binary, opposite `list_dir` — proving enumerability is
7599        // per-directory and copied exactly, not inferred or unified.
7600        let grants = resolve_interpreter_runtime_grants(&[interp_grant(
7601            "python3",
7602            &[
7603                ("/usr/lib/python3.11", true),
7604                ("/usr/lib/python3.11/lib-dynload", false),
7605            ],
7606        )]);
7607
7608        assert_eq!(
7609            grants,
7610            vec![
7611                LandlockGrant {
7612                    path: PathBuf::from("/usr/lib/python3.11"),
7613                    list_dir: true,
7614                    executable: true,
7615                    pinned: None,
7616                },
7617                LandlockGrant {
7618                    path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
7619                    list_dir: false,
7620                    executable: true,
7621                    pinned: None,
7622                },
7623            ]
7624        );
7625    }
7626
7627    #[test]
7628    fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
7629        // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
7630        // scope` skips any that fail to open), and multiple grants flatten in order.
7631        let grants = resolve_interpreter_runtime_grants(&[
7632            interp_grant("python3", &[("/opt/py/nonexistent", false)]),
7633            interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
7634        ]);
7635
7636        assert_eq!(
7637            grants,
7638            vec![
7639                LandlockGrant {
7640                    path: PathBuf::from("/opt/py/nonexistent"),
7641                    list_dir: false,
7642                    executable: true,
7643                    pinned: None,
7644                },
7645                LandlockGrant {
7646                    path: PathBuf::from("/opt/rb/also-nonexistent"),
7647                    list_dir: true,
7648                    executable: true,
7649                    pinned: None,
7650                },
7651            ]
7652        );
7653        assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
7654    }
7655
7656    #[test]
7657    fn non_listable_files_marks_every_closure_file_non_enumerable() {
7658        // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
7659        // (ReadDir on a regular file is a no-op anyway).
7660        let wrapped = LandlockGrant::non_listable_files(vec![
7661            PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
7662            PathBuf::from("/usr/bin/bash"),
7663        ]);
7664        assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
7665        assert_eq!(
7666            wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
7667            vec![
7668                PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
7669                PathBuf::from("/usr/bin/bash"),
7670            ]
7671        );
7672    }
7673
7674    // ---- native tool entrypoints ------------------------------------------------------------
7675
7676    fn write_entrypoint(dir: &Path, name: &str, head: &[u8]) -> PathBuf {
7677        let path = dir.join(name);
7678        std::fs::write(&path, head).unwrap();
7679        path
7680    }
7681
7682    /// `path` as the runtime records a native tool it has just written there, inside `workdir`.
7683    fn installed(path: &Path, workdir: &Path) -> NativeEntrypoint {
7684        NativeEntrypoint::derive(path, &std::fs::read(path).unwrap(), workdir)
7685    }
7686
7687    #[test]
7688    fn shebang_interpreter_reads_the_absolute_interpreter_and_nothing_else() {
7689        let cases: &[(&[u8], Option<&str>)] = &[
7690            (b"#!/bin/sh\necho hi\n", Some("/bin/sh")),
7691            (b"#! /usr/bin/python3 -u\n", Some("/usr/bin/python3")),
7692            // `env` is what the kernel runs; `python3` is found by `env` and needs its own grant.
7693            (b"#!/usr/bin/env python3\n", Some("/usr/bin/env")),
7694            (b"#!sh\n", None),
7695            (b"#!\n", None),
7696            (b"\x7fELF\x02\x01\x01", None),
7697            (b"", None),
7698        ];
7699        for (head, expected) in cases {
7700            assert_eq!(
7701                shebang_interpreter(head),
7702                expected.map(PathBuf::from),
7703                "{:?}",
7704                String::from_utf8_lossy(head)
7705            );
7706        }
7707
7708        // A `#!` line longer than the kernel's own buffer names nothing past it.
7709        let mut long = b"#!".to_vec();
7710        long.extend(std::iter::repeat_n(b' ', SHEBANG_READ_LIMIT as usize));
7711        long.extend(b"/bin/sh\n");
7712        assert_eq!(shebang_interpreter(&long), None);
7713    }
7714
7715    /// The entrypoint, pinned to its inode, and its interpreter are added as `Execute + ReadFile`
7716    /// file grants, and nothing is taken away from the enforcement they extend.
7717    #[test]
7718    fn for_native_entrypoint_adds_the_pinned_entrypoint_and_its_interpreter_only() {
7719        let dir = tempfile::tempdir().unwrap();
7720        let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
7721        let existing = LandlockGrant {
7722            path: PathBuf::from("/usr/bin/git"),
7723            list_dir: false,
7724            executable: true,
7725            pinned: None,
7726        };
7727        let base = ShellEnforcement {
7728            tier: EnforcementTier::KernelFull,
7729            landlock_grants: vec![existing.clone()],
7730            ..ShellEnforcement::environment_only().with_test_session_dirs()
7731        };
7732
7733        let native = base.for_native_entrypoint(&installed(&entrypoint, dir.path()));
7734
7735        assert_eq!(native.landlock_grants[0], existing);
7736        let added: Vec<&LandlockGrant> = native.landlock_grants[1..].iter().collect();
7737        let identity = FileIdentity::of(&std::fs::metadata(&entrypoint).unwrap());
7738        assert!(
7739            added
7740                .iter()
7741                .any(|grant| grant.path == entrypoint && grant.pinned == Some(identity)),
7742            "the entrypoint must be granted, pinned to its inode: {added:?}"
7743        );
7744        let canonical_sh = std::fs::canonicalize("/bin/sh").unwrap();
7745        assert!(
7746            added.iter().any(|grant| grant.path == canonical_sh),
7747            "/bin/sh must be granted: {added:?}"
7748        );
7749        assert!(
7750            added
7751                .iter()
7752                .all(|grant| grant.executable && !grant.list_dir),
7753            "{added:?}"
7754        );
7755        // The closure is files, never a directory such as the workdir or /etc.
7756        assert!(added.iter().all(|grant| !grant.path.is_dir()), "{added:?}");
7757        assert!(native.sealed_bind_dirs.is_empty(), "not sealed: no binds");
7758        assert!(
7759            Arc::ptr_eq(&native.session_netns, &base.session_netns),
7760            "a native spawn joins the session's own namespace"
7761        );
7762        assert_eq!(
7763            base.landlock_grants.len(),
7764            1,
7765            "the session's grants are untouched"
7766        );
7767    }
7768
7769    /// On `KernelSealed`, an interpreter outside the fixed runtime paths has its directory bound
7770    /// into the composed root as an optional bind; one inside them adds nothing.
7771    #[test]
7772    fn for_native_entrypoint_binds_an_interpreter_outside_the_runtime_paths_when_sealed() {
7773        let dir = tempfile::tempdir().unwrap();
7774        let sealed = ShellEnforcement {
7775            tier: EnforcementTier::KernelSealed,
7776            ..ShellEnforcement::environment_only().with_test_session_dirs()
7777        };
7778
7779        let system = installed(
7780            &write_entrypoint(dir.path(), "system", b"#!/bin/sh\n"),
7781            dir.path(),
7782        );
7783        assert!(sealed
7784            .for_native_entrypoint(&system)
7785            .sealed_bind_dirs
7786            .is_empty());
7787
7788        let custom = installed(
7789            &write_entrypoint(dir.path(), "custom", b"#!/opt/tools/bin/run\n"),
7790            dir.path(),
7791        );
7792        assert_eq!(
7793            sealed.for_native_entrypoint(&custom).sealed_bind_dirs,
7794            vec![PathBuf::from("/opt/tools/bin")]
7795        );
7796
7797        let scoped = ShellEnforcement {
7798            tier: EnforcementTier::KernelFull,
7799            ..ShellEnforcement::environment_only().with_test_session_dirs()
7800        };
7801        assert!(scoped
7802            .for_native_entrypoint(&custom)
7803            .sealed_bind_dirs
7804            .is_empty());
7805    }
7806
7807    /// The capsule can rewrite the installed entrypoint between calls. Whatever it writes, the
7808    /// next spawn is granted exactly what the installed image was: a new `#!` interpreter, on any
7809    /// tier, adds no exec grant and no composed-root bind.
7810    #[test]
7811    fn a_rewritten_entrypoint_gains_no_grant() {
7812        let dir = tempfile::tempdir().unwrap();
7813        let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
7814        let native = installed(&entrypoint, dir.path());
7815
7816        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
7817            let base = ShellEnforcement {
7818                tier,
7819                ..ShellEnforcement::environment_only().with_test_session_dirs()
7820            };
7821            let before = base.for_native_entrypoint(&native);
7822
7823            std::fs::write(&entrypoint, b"#!/opt/elsewhere/bin/run\n").unwrap();
7824            let after = base.for_native_entrypoint(&native);
7825            std::fs::write(&entrypoint, b"#!/bin/sh\ncat\n").unwrap();
7826
7827            assert_eq!(after.landlock_grants, before.landlock_grants, "{tier:?}");
7828            assert_eq!(after.sealed_bind_dirs, before.sealed_bind_dirs, "{tier:?}");
7829            assert!(
7830                after
7831                    .landlock_grants
7832                    .iter()
7833                    .all(|grant| !grant.path.starts_with("/opt/elsewhere")),
7834                "{tier:?}: {:?}",
7835                after.landlock_grants
7836            );
7837        }
7838    }
7839
7840    /// The dependency walk neither grants nor reads a file under the accessible workdir, where the
7841    /// capsule could plant a library whose own entries widen the closure.
7842    #[test]
7843    fn the_entrypoint_closure_does_not_reach_into_the_workdir() {
7844        let temp = tempfile::tempdir().unwrap();
7845        let workdir = temp.path().join("workdir");
7846        let tool_dir = workdir.join("tools").join("tool");
7847        std::fs::create_dir_all(&tool_dir).unwrap();
7848        let system = temp.path().join("system");
7849        std::fs::create_dir(&system).unwrap();
7850        std::fs::write(system.join("libsys.so"), "x").unwrap();
7851        let outside = temp.path().join("outside");
7852        std::fs::create_dir(&outside).unwrap();
7853        std::fs::write(outside.join("libpicked.so"), "x").unwrap();
7854
7855        // Planted next to the entrypoint, found through `$ORIGIN`, and naming a file outside.
7856        std::fs::write(
7857            tool_dir.join("libplanted.so"),
7858            build_elf64(
7859                None,
7860                &[outside.join("libpicked.so").to_str().unwrap()],
7861                None,
7862            ),
7863        )
7864        .unwrap();
7865        // A symlink next to the entrypoint: canonicalising it lands outside the workdir, so only
7866        // refusing the `$ORIGIN` lookup itself keeps the host file out of the closure.
7867        std::fs::write(outside.join("host-binary"), "x").unwrap();
7868        std::os::unix::fs::symlink(outside.join("host-binary"), tool_dir.join("liblinked.so"))
7869            .unwrap();
7870        let image = build_elf64(
7871            None,
7872            &["libplanted.so", "liblinked.so", "libsys.so"],
7873            Some("$ORIGIN"),
7874        );
7875        let entrypoint = tool_dir.join("tool");
7876        std::fs::write(&entrypoint, &image).unwrap();
7877
7878        let native = NativeEntrypoint::derive_in(
7879            &entrypoint,
7880            &image,
7881            &workdir,
7882            std::slice::from_ref(&system),
7883        );
7884
7885        let sys = std::fs::canonicalize(system.join("libsys.so")).unwrap();
7886        assert_eq!(native.closure, vec![sys]);
7887    }
7888
7889    /// A pinned grant whose path no longer opens to the pinned inode is dropped: replacing the
7890    /// entrypoint, here with a symlink to a host binary, leaves it with no `Execute` rule.
7891    #[cfg(target_os = "linux")]
7892    #[test]
7893    fn a_replaced_entrypoint_loses_its_pinned_grant() {
7894        let accessible = tempfile::tempdir().unwrap();
7895        let session = accessible
7896            .path()
7897            .join(".murmur")
7898            .join("ses_native_entrypoint");
7899        std::fs::create_dir_all(&session).unwrap();
7900        let entrypoint = write_entrypoint(&session, "tool", b"#!/bin/sh\n");
7901        let native = installed(&entrypoint, accessible.path());
7902        let grants = ShellEnforcement {
7903            tier: EnforcementTier::KernelFull,
7904            ..ShellEnforcement::environment_only().with_test_session_dirs()
7905        }
7906        .for_native_entrypoint(&native)
7907        .landlock_grants;
7908        let open = |grants: &[LandlockGrant]| {
7909            linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
7910                landlock_grants: grants,
7911                ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
7912            })
7913            .unwrap()
7914            .grant_count()
7915        };
7916        let installed_count = open(&grants);
7917        // Holds the installed inode for the rest of the test. Freed, its number may be handed to
7918        // the next file created on this filesystem, which would then match the pin.
7919        std::fs::hard_link(&entrypoint, session.join("installed")).unwrap();
7920
7921        std::fs::remove_file(&entrypoint).unwrap();
7922        std::os::unix::fs::symlink("/bin/sh", &entrypoint).unwrap();
7923        assert_eq!(open(&grants), installed_count - 1, "symlink swapped in");
7924
7925        std::fs::remove_file(&entrypoint).unwrap();
7926        std::fs::write(&entrypoint, b"#!/bin/sh\n").unwrap();
7927        assert_eq!(open(&grants), installed_count - 1, "new file, new inode");
7928    }
7929
7930    // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
7931    //
7932    // The canonical statement of what the *content* checks in this file are worth, referenced by
7933    // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
7934    // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
7935    // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
7936    // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
7937    // buys one thing: changing a constant cannot happen by accident.
7938    //
7939    // The enforcement claim for this constant is verified only by the manual procedure in
7940    // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
7941    // uncontainerized Linux hardware.
7942
7943    #[test]
7944    fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
7945        let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
7946            .iter()
7947            .map(|device| (device.path, device.writable))
7948            .collect();
7949        assert_eq!(
7950            listed,
7951            vec![
7952                ("/dev/null", true),
7953                ("/dev/zero", false),
7954                ("/dev/urandom", false),
7955            ],
7956            "the capsule device set is fixed at exactly these three paths and these access \
7957             levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
7958             and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
7959             both. Widening this list is allowed only on demonstrated workload failure, and the \
7960             failure gets recorded alongside the widening (see the manual acceptance procedure on \
7961             the diagnostics reference page)."
7962        );
7963    }
7964
7965    #[test]
7966    fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
7967        // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
7968        // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
7969        // shell child has dropped). It is excluded because nothing needs a blocking RNG when
7970        // /dev/urandom is granted — not because writing to it would poison the kernel pool.
7971        for excluded in [
7972            "/dev/random",
7973            "/dev/full",
7974            "/dev/tty",
7975            "/dev/console",
7976            "/dev/mem",
7977            "/dev/sda",
7978            "/dev",
7979        ] {
7980            assert!(
7981                !CAPSULE_DEVICE_GRANTS
7982                    .iter()
7983                    .any(|device| device.path == excluded),
7984                "{excluded} must not be in the capsule device set — it is denied by the ordinary \
7985                 no-matching-rule path, and adding it here would be the only way to change that"
7986            );
7987        }
7988    }
7989
7990    #[test]
7991    fn capsule_device_grants_name_absolute_paths_under_dev() {
7992        // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
7993        // against whatever cwd the host process happens to have.
7994        for device in CAPSULE_DEVICE_GRANTS {
7995            assert!(
7996                device.path.starts_with("/dev/"),
7997                "{} must be an absolute path under /dev",
7998                device.path
7999            );
8000        }
8001    }
8002
8003    // ---- `denied_socket_domains` ----------------------------------------------------------
8004    //
8005    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
8006    // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
8007    // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
8008    // real Linux hardware.
8009
8010    #[test]
8011    fn denied_socket_domains_denies_unix_by_default() {
8012        let denied = denied_socket_domains(false);
8013        assert!(
8014            denied.contains(&LINUX_AF_UNIX),
8015            "AF_UNIX must be denied when the manifest does not declare \
8016             capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
8017        );
8018    }
8019
8020    #[test]
8021    fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
8022        let denied = denied_socket_domains(true);
8023        assert!(
8024            !denied.contains(&LINUX_AF_UNIX),
8025            "a capsule that declared capabilities.network.unix_sockets: true must get no \
8026             AF_UNIX deny rule at all"
8027        );
8028    }
8029
8030    /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
8031    /// identical for both values of the one flag that exists.
8032    #[test]
8033    fn denied_socket_domains_always_denies_netlink_and_packet() {
8034        for unix_sockets_allowed in [false, true] {
8035            let denied = denied_socket_domains(unix_sockets_allowed);
8036            assert!(
8037                denied.contains(&LINUX_AF_NETLINK),
8038                "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
8039                 {unix_sockets_allowed})"
8040            );
8041            assert!(
8042                denied.contains(&LINUX_AF_PACKET),
8043                "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
8044                 {unix_sockets_allowed})"
8045            );
8046        }
8047    }
8048
8049    /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
8050    /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
8051    /// deny.
8052    #[test]
8053    fn denied_socket_domains_never_denies_ip_families() {
8054        for unix_sockets_allowed in [false, true] {
8055            let denied = denied_socket_domains(unix_sockets_allowed);
8056            assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
8057            assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
8058        }
8059    }
8060
8061    /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
8062    /// an unnoticed side effect of touching the function.
8063    #[test]
8064    fn denied_socket_domains_returns_exactly_the_documented_sets() {
8065        assert_eq!(
8066            denied_socket_domains(false),
8067            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
8068        );
8069        assert_eq!(
8070            denied_socket_domains(true),
8071            vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
8072        );
8073    }
8074
8075    /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
8076    /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
8077    /// they really do agree with `libc`; on macOS this test compiles away, because
8078    /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
8079    /// constants are literals.
8080    #[cfg(target_os = "linux")]
8081    #[test]
8082    fn linux_socket_domain_constants_match_libc() {
8083        assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
8084        assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
8085        assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
8086        assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
8087        assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
8088    }
8089
8090    /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
8091    /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
8092    /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
8093    /// anything stated here.
8094    #[test]
8095    fn socket_domain_allow_and_deny_sets_never_overlap() {
8096        for unix_sockets_allowed in [false, true] {
8097            let denied = denied_socket_domains(unix_sockets_allowed);
8098            for domain in allowed_socket_domains(unix_sockets_allowed) {
8099                assert!(
8100                    !denied.contains(&domain),
8101                    "socket domain {domain} is both allowed and denied \
8102                     (unix_sockets_allowed = {unix_sockets_allowed})"
8103                );
8104            }
8105        }
8106    }
8107
8108    /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
8109    /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
8110    /// default-deny filter would silently keep unix sockets refused even after a capsule declared
8111    /// the capability.
8112    #[test]
8113    fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
8114        assert_eq!(
8115            allowed_socket_domains(false),
8116            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
8117        );
8118        assert_eq!(
8119            allowed_socket_domains(true),
8120            vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
8121        );
8122    }
8123
8124    // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
8125    //
8126    // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
8127    // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
8128    // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
8129    // the allowlist against a newer upstream profile.
8130
8131    #[test]
8132    fn allowlist_contains_no_syscall_that_must_stay_denied() {
8133        for name in SECCOMP_MUST_STAY_DENIED {
8134            assert!(
8135                !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
8136                "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
8137                 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
8138                 in the same edit and say why in the commit"
8139            );
8140        }
8141    }
8142
8143    /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
8144    /// so omitting them would refuse the child's own first `execve` and no capsule could run a
8145    /// shell tool at all. Exec is decided by the Landlock domain instead; see
8146    /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
8147    #[test]
8148    fn allowlist_permits_exec_because_landlock_now_decides_it() {
8149        for name in ["execve", "execveat"] {
8150            assert!(
8151                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8152                "{name} must reach the kernel — the default action is a deny, so omitting it \
8153                 refuses the child's own exec and nothing runs"
8154            );
8155        }
8156    }
8157
8158    /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
8159    /// for a syscall by discarding every conditional chain already recorded for it. A bare
8160    /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
8161    /// denials at filter-build time, with nothing at runtime to show for it.
8162    #[test]
8163    fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
8164        assert!(
8165            !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
8166            "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
8167        );
8168    }
8169
8170    /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
8171    /// which is exactly the state where one of them later gets removed and the other is missed.
8172    #[test]
8173    fn allowlist_has_no_duplicates() {
8174        let mut seen = std::collections::HashSet::new();
8175        for name in SECCOMP_SYSCALL_ALLOWLIST {
8176            assert!(seen.insert(*name), "{name} appears twice in the allowlist");
8177        }
8178    }
8179
8180    /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
8181    /// still "pass" every other test here, while denying every syscall a shell needs.
8182    #[test]
8183    fn allowlist_covers_the_syscalls_no_process_can_run_without() {
8184        for name in [
8185            "read",
8186            "write",
8187            "openat",
8188            "close",
8189            "mmap",
8190            "munmap",
8191            "brk",
8192            "exit_group",
8193            "futex",
8194            "rt_sigreturn",
8195            "clone",
8196            "wait4",
8197            "getpid",
8198            "getdents64",
8199        ] {
8200            assert!(
8201                SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8202                "{name} is missing from the allowlist — no process could run under this filter"
8203            );
8204        }
8205    }
8206
8207    #[test]
8208    fn network_ip_allowed_matches_only_the_resolved_allowlist() {
8209        let allow = vec![IpAddr::from([127, 0, 0, 1])];
8210        assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
8211        assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
8212        assert!(
8213            !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
8214            "an empty resolved allowlist must deny every destination"
8215        );
8216    }
8217
8218    // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
8219
8220    #[test]
8221    fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
8222        // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
8223        // namespace, and a silent tier would imply it has no documented limits. It warns when a
8224        // shell is declared, and stays silent only when there is no subprocess to enforce on.
8225        assert_eq!(
8226            tier_warning(EnforcementTier::KernelFull, false),
8227            Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
8228        );
8229        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8230    }
8231
8232    #[test]
8233    fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
8234        assert_eq!(
8235            tier_warning(EnforcementTier::KernelSeccompOnly, false),
8236            Some((W_SEC_002, SECCOMP_ONLY_WARNING))
8237        );
8238        assert_eq!(
8239            tier_warning(EnforcementTier::EnvironmentOnly, false),
8240            Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
8241        );
8242        assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8243        assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
8244        assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
8245    }
8246
8247    /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
8248    /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
8249    /// same condition refuses the launch outright, so a warning would describe a session that
8250    /// does not exist — and never for a capsule with no process tree to bound.
8251    #[test]
8252    fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
8253        assert_eq!(
8254            aggregate_bounding_warning(false, true, false),
8255            Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
8256        );
8257        assert_eq!(
8258            aggregate_bounding_warning(false, false, false),
8259            None,
8260            "a capsule that cannot spawn a subprocess has nothing to bound"
8261        );
8262        assert_eq!(
8263            aggregate_bounding_warning(true, true, false),
8264            None,
8265            "on Linux this case is a refused launch, not a warning"
8266        );
8267        assert_eq!(
8268            aggregate_bounding_warning(true, true, true),
8269            None,
8270            "a scope exists, so there is no gap"
8271        );
8272    }
8273
8274    /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
8275    /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
8276    /// would expect to stop a fork bomb does not.
8277    #[test]
8278    fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
8279        let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
8280        assert!(message.contains("per-uid"), "message was: {message}");
8281        assert!(message.contains("fork bomb"), "message was: {message}");
8282        assert!(message.contains("pids.max"), "message was: {message}");
8283    }
8284
8285    #[test]
8286    fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
8287        let temp = tempfile::tempdir().unwrap();
8288        warn_for_missing_aggregate_bounding(
8289            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8290            true,
8291            false,
8292        );
8293        let log = bootstrap_log_contents(temp.path());
8294
8295        if cfg!(target_os = "linux") {
8296            assert!(
8297                log.is_empty(),
8298                "Linux refuses the launch instead of warning: {log}"
8299            );
8300        } else {
8301            assert!(log.contains(W_SEC_010), "log was: {log}");
8302            assert!(
8303                log.contains(&security_warning_link(W_SEC_010)),
8304                "log was: {log}"
8305            );
8306        }
8307    }
8308
8309    fn warning_test_policy() -> CapabilityPolicy {
8310        CapabilityPolicy {
8311            shell_allow: vec!["bash".to_string()],
8312            network_allow: vec!["https://api.example.com".to_string()],
8313            ..CapabilityPolicy::default()
8314        }
8315    }
8316
8317    #[test]
8318    fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
8319        let temp = tempfile::tempdir().unwrap();
8320        warn_for_enforcement_tier(
8321            EnforcementTier::KernelFull,
8322            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8323            &warning_test_policy(),
8324        );
8325        let log = bootstrap_log_contents(temp.path());
8326        assert!(
8327            log.contains(W_SEC_005),
8328            "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
8329        );
8330        assert!(
8331            log.contains("denied, not absent"),
8332            "must state the scope's documented limit: paths outside the workdir are denied, not \
8333             absent: {log}"
8334        );
8335        assert!(
8336            log.contains("read+execute") && log.contains("outside the workdir"),
8337            "must describe what is now actually granted (narrow, derived read+execute outside \
8338             the workdir), not an unfixed grant bug: {log}"
8339        );
8340        assert!(
8341            log.contains(&security_warning_link(W_SEC_005)),
8342            "must link to the diagnostics doc page: {log}"
8343        );
8344    }
8345
8346    #[test]
8347    fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
8348        let temp = tempfile::tempdir().unwrap();
8349        warn_for_enforcement_tier(
8350            EnforcementTier::KernelFull,
8351            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8352            &CapabilityPolicy::default(),
8353        );
8354        assert_eq!(
8355            bootstrap_log_contents(temp.path()),
8356            "",
8357            "with no shell declared there is no subprocess to enforce on — stay silent"
8358        );
8359    }
8360
8361    #[test]
8362    fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
8363        let temp = tempfile::tempdir().unwrap();
8364        warn_for_enforcement_tier(
8365            EnforcementTier::KernelSeccompOnly,
8366            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8367            &warning_test_policy(),
8368        );
8369        let log = bootstrap_log_contents(temp.path());
8370        assert!(
8371            log.contains("Landlock"),
8372            "must name the missing primitive: {log}"
8373        );
8374        assert!(
8375            log.contains("filesystem"),
8376            "must name what is not enforced: {log}"
8377        );
8378        assert!(
8379            log.contains(W_SEC_002),
8380            "must carry its warning code: {log}"
8381        );
8382        assert!(
8383            log.contains(&security_warning_link(W_SEC_002)),
8384            "must link to the diagnostics doc page: {log}"
8385        );
8386    }
8387
8388    #[test]
8389    fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
8390        let temp = tempfile::tempdir().unwrap();
8391        warn_for_enforcement_tier(
8392            EnforcementTier::EnvironmentOnly,
8393            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8394            &warning_test_policy(),
8395        );
8396        let log = bootstrap_log_contents(temp.path());
8397        assert!(
8398            log.contains("permanent"),
8399            "must state the tier is permanent, not pending: {log}"
8400        );
8401        assert!(
8402            log.contains("bash"),
8403            "the still-accurate bash/network bypass warning must also fire: {log}"
8404        );
8405        assert!(
8406            log.contains(W_SEC_001),
8407            "must carry the tier warning code: {log}"
8408        );
8409        assert!(
8410            log.contains(W_SEC_003),
8411            "must carry the bash/network bypass warning code: {log}"
8412        );
8413    }
8414
8415    // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
8416
8417    struct ForcePrepareFailureGuard;
8418
8419    impl ForcePrepareFailureGuard {
8420        fn new() -> Self {
8421            FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
8422            Self
8423        }
8424    }
8425
8426    impl Drop for ForcePrepareFailureGuard {
8427        fn drop(&mut self) {
8428            FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
8429        }
8430    }
8431
8432    #[test]
8433    fn execute_shell_fails_closed_when_enforcement_setup_fails() {
8434        let temp = tempfile::tempdir().unwrap();
8435        let policy = CapabilityPolicy {
8436            shell_allow: vec!["bash".to_string()],
8437            ..CapabilityPolicy::default()
8438        };
8439        let marker = temp.path().join("spawned-anyway.marker");
8440        let script = format!("echo ran > '{}'", marker.display());
8441
8442        {
8443            let _guard = ForcePrepareFailureGuard::new();
8444            let error = crate::shell::execute_shell(
8445                "bash",
8446                &["-c", &script],
8447                &[],
8448                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8449                &policy,
8450                &ShellEnforcement::environment_only().with_test_session_dirs(),
8451            )
8452            .unwrap_err();
8453            assert!(
8454                error.to_string().contains("sandbox"),
8455                "the error must name what failed to initialize: {error}"
8456            );
8457            assert!(
8458                !marker.exists(),
8459                "no subprocess may be spawned at all when enforcement setup fails"
8460            );
8461        }
8462
8463        // Control run without the forced failure: the identical call spawns and writes the
8464        // marker — proving the marker really would have caught a spawn above.
8465        let result = crate::shell::execute_shell(
8466            "bash",
8467            &["-c", &script],
8468            &[],
8469            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8470            &policy,
8471            &ShellEnforcement::environment_only().with_test_session_dirs(),
8472        )
8473        .unwrap();
8474        assert_eq!(result.exit_code, 0);
8475        assert!(marker.exists());
8476    }
8477}
8478
8479#[cfg(target_os = "linux")]
8480#[cfg(test)]
8481#[allow(clippy::print_stdout, clippy::print_stderr)]
8482mod landlock_probe_isolation {
8483    /// The Landlock probe must leave the calling process exactly as it found it.
8484    ///
8485    /// Stated as "the sealed probe gives the same answer before and after" rather than by
8486    /// inspecting the process's Landlock domain, because there is no API to ask whether a task
8487    /// is landlocked — and because that framing is what actually broke: the probe restricted
8488    /// itself, and every later attempt to build a composed root was refused, on every host, in
8489    /// a way that looked like the host's fault.
8490    ///
8491    /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
8492    /// halves report the same failure and this passes trivially; on a capable one, an
8493    /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
8494    #[test]
8495    fn the_landlock_probe_does_not_restrict_the_calling_process() {
8496        let before = crate::namespace_probe::probe_namespaces().sealed_root;
8497        let _ = super::linux_enforce::probe_landlock_full_access();
8498        let after = crate::namespace_probe::probe_namespaces().sealed_root;
8499
8500        assert_eq!(
8501            before, after,
8502            "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
8503             it has restricted the calling process instead of a forked child, which permanently \
8504             forbids the mount family to this process and everything it spawns",
8505        );
8506    }
8507}
8508
8509#[cfg(all(test, target_os = "linux"))]
8510#[allow(clippy::print_stdout, clippy::print_stderr)]
8511mod linux_integration_tests {
8512    use std::path::Path;
8513
8514    use super::*;
8515
8516    /// Spread-base supplying the three *host-bounding* fields (`resource_limits`,
8517    /// `cgroup_scope`, `workdir_guard`) to the enforcement literals below, which spell out the
8518    /// policy half by hand because they need to pin `tier` explicitly.
8519    ///
8520    /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
8521    /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
8522    /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
8523    /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
8524    /// of the child `pre_exec` runs on every tier), and no cgroup scope or workdir guard, so each
8525    /// spawn gets `RLIMIT_NPROC` headroom over the uid's count at that spawn. The `tier` it
8526    /// carries is always overridden by the literal that spreads it; only the host-bounding tail
8527    /// is ever consumed from here.
8528    fn host_bounding_base() -> ShellEnforcement {
8529        // Every session holds a runtime directory, and a sealed one cannot compose a root
8530        // without it. Kept past the test: a clone may outlive the value.
8531        let base = tempfile::tempdir().unwrap().keep();
8532        ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
8533    }
8534
8535    /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
8536    /// It pins *which* rights of [`LANDLOCK_TARGET_ABI`] `apply_landlock_scope` hands the workdir,
8537    /// so re-adding device-node creation, or losing the move and truncate rights, becomes a test
8538    /// failure instead of a silent regression.
8539    ///
8540    /// It proves **nothing** about whether the kernel enforces that set — that is the manual
8541    /// acceptance procedure in
8542    /// `docs/content/reference/workdir-device-node-manual-verification.md` and the
8543    /// `kernel_tier_*` tests below, not this test.
8544    /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
8545    /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
8546    #[test]
8547    fn workdir_landlock_grant_withholds_device_and_socket_creation() {
8548        use landlock::{Access, AccessFs, ABI};
8549
8550        assert_eq!(
8551            LANDLOCK_TARGET_ABI, 3,
8552            "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
8553        );
8554        let all_v3 = AccessFs::from_all(ABI::V3);
8555        // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
8556        // unconditionally-withheld rights below are checked against the permissive case rather
8557        // than passing trivially because `Execute` happened to be absent too.
8558        let granted = linux_enforce::workdir_access_rights(true);
8559
8560        for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
8561            assert!(
8562                all_v3.contains(withheld),
8563                "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
8564                 test's premise (and the workdir grant's comment) needs revisiting"
8565            );
8566            assert!(
8567                !granted.contains(withheld),
8568                "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
8569                 by handle_access, withholding it here is what makes it denied rather than merely \
8570                 un-granted"
8571            );
8572        }
8573
8574        for added in [AccessFs::Refer, AccessFs::Truncate] {
8575            assert!(
8576                granted.contains(added),
8577                "the workdir grant must carry {added:?}: without Refer a move between workdir \
8578                 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
8579                 capsule's own files"
8580            );
8581        }
8582
8583        assert!(
8584            granted.contains(AccessFs::MakeFifo),
8585            "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
8586        );
8587
8588        let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
8589        assert_eq!(
8590            granted, expected,
8591            "the workdir_exec grant must be exactly Landlock ABI v3 minus \
8592             MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
8593             this test and the constant's comment together"
8594        );
8595    }
8596
8597    /// The workdir right set as a ruleset built at ABI v1 sees it, after `apply_landlock_scope`
8598    /// masks it to the handled set: exactly ABI v1 minus the three `Make*` rights, with neither
8599    /// `Refer` nor `Truncate`, so a 5.13–5.18 kernel is handed no right it does not know.
8600    #[test]
8601    fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
8602        use landlock::{Access, AccessFs, ABI};
8603
8604        let all_v1 = AccessFs::from_all(ABI::V1);
8605        let masked = linux_enforce::workdir_access_rights(true) & all_v1;
8606
8607        assert_eq!(
8608            masked,
8609            all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
8610            "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
8611        );
8612        assert!(!masked.contains(AccessFs::Refer));
8613        assert!(!masked.contains(AccessFs::Truncate));
8614        assert_eq!(
8615            linux_enforce::workdir_access_rights(false) & all_v1,
8616            masked & !AccessFs::Execute,
8617            "masking must not move the workdir_exec axis"
8618        );
8619    }
8620
8621    /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
8622    /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
8623    /// procedure in
8624    /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
8625    /// regression that matters here: flipping the default back — which would silently reopen the
8626    /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
8627    #[test]
8628    fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
8629        use landlock::AccessFs;
8630
8631        assert!(
8632            !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
8633            "the default must withhold Execute from the workdir — that withholding is the whole \
8634             of what enforces capabilities.shell.allow now that the exec supervisor is gone"
8635        );
8636        assert!(
8637            linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
8638            "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
8639             compile-and-run workflows it exists for silently stop working"
8640        );
8641
8642        // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
8643        assert_eq!(
8644            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
8645            linux_enforce::workdir_access_rights(false),
8646            "workdir_exec must be a one-bit axis — no other right may ride along with it"
8647        );
8648    }
8649
8650    /// A sealed enforcement over a fresh runtime directory beside `accessible`.
8651    fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
8652        ShellEnforcement {
8653            tier: EnforcementTier::KernelSealed,
8654            ..ShellEnforcement::environment_only().with_test_session_dirs()
8655        }
8656        .with_runtime_dir(test_runtime_dir(base), None)
8657    }
8658
8659    /// The session's temporary folder's descriptor, as `prepare_enforcement` takes it from the
8660    /// held root.
8661    fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
8662        enforcement
8663            .runtime_dir
8664            .as_ref()
8665            .and_then(|roots| roots.temp.as_ref())
8666            .map(|temp| Arc::new(temp.path_fd().unwrap()))
8667    }
8668
8669    /// The session's home folder's descriptor, as `prepare_enforcement` takes it from the held
8670    /// root.
8671    fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
8672        enforcement
8673            .home_root()
8674            .map(|home| Arc::new(home.path_fd().unwrap()))
8675    }
8676
8677    /// The sealed sources take the session's temporary folder from the runtime directory roots,
8678    /// bind it from its own path, and add nothing to either workdir; the Landlock fds carry the
8679    /// folder's rule whenever one is handed in. Opening an fd is an ordinary `open()` — no
8680    /// Landlock call, no namespace, no privilege — so this runs on any Linux host.
8681    ///
8682    /// It proves **nothing** about whether the kernel then honors the rule built from that fd;
8683    /// the live tests below and the hand-run procedure in
8684    /// `docs/content/reference/sealed-containment-manual-verification.md` do.
8685    #[test]
8686    fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
8687        let accessible = tempfile::tempdir().unwrap();
8688        let runtime = tempfile::tempdir().unwrap();
8689        let enforcement = sealed_with_runtime_dir(runtime.path());
8690        let temp_dir_fd = temp_dir_fd_of(&enforcement);
8691        let sources = SealedSourceFds::open(
8692            &enforcement,
8693            temp_dir_fd.as_ref(),
8694            home_dir_fd_of(&enforcement).as_ref(),
8695        )
8696        .expect("a real, writable workdir must yield the sealed sources");
8697        assert_eq!(
8698            sources.tmp_store_path,
8699            enforcement.temp_dir_path().unwrap(),
8700            "the composed root binds the session's temporary folder at /tmp"
8701        );
8702        assert_eq!(
8703            sources.home_dir.1,
8704            enforcement.home_dir_path().unwrap(),
8705            "the composed root binds the session's home folder at its own path"
8706        );
8707        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
8708            sealed: Some(&sources),
8709            temp_dir_fd,
8710            home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
8711            runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
8712            records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
8713            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
8714        })
8715        .expect("a real, writable workdir must yield sealed Landlock fds");
8716
8717        assert!(fds.temp_dir_fd().is_some());
8718        assert!(
8719            fds.home_dir_fd().is_some(),
8720            "the home folder takes the temporary folder's rule"
8721        );
8722        assert!(fds.runtime_dir_fd().is_some());
8723        assert!(
8724            fds.records_dir_fd().is_some(),
8725            "the records directory takes the runtime directory's read-only rule"
8726        );
8727        assert_eq!(
8728            std::fs::read_dir(accessible.path()).unwrap().count(),
8729            0,
8730            "enabling sealed must add nothing to the work folder"
8731        );
8732
8733        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
8734            accessible.path(),
8735        ))
8736        .expect("a real, writable workdir must yield Landlock fds on any tier");
8737        assert!(
8738            fds.temp_dir_fd().is_none(),
8739            "an enforcement holding no temporary folder grants none"
8740        );
8741        assert!(fds.home_dir_fd().is_none());
8742    }
8743
8744    /// A sealed root cannot be composed without the session's temporary folder: the refusal is
8745    /// the parent's, names the folder, and comes before anything is spawned.
8746    #[test]
8747    fn a_sealed_root_needs_the_temp_folder() {
8748        let runtime = tempfile::tempdir().unwrap();
8749        let mut roots = test_runtime_dir(runtime.path());
8750        roots.temp = None;
8751        let enforcement = ShellEnforcement {
8752            tier: EnforcementTier::KernelSealed,
8753            ..ShellEnforcement::environment_only().with_test_session_dirs()
8754        }
8755        .with_runtime_dir(roots, None);
8756        let error =
8757            SealedSourceFds::open(&enforcement, None, home_dir_fd_of(&enforcement).as_ref())
8758                .err()
8759                .expect("refused");
8760        assert!(error.contains("temporary folder"), "{error}");
8761    }
8762
8763    /// Nor without its home folder: the refusal names the missing home folder, and comes before
8764    /// anything is spawned or written.
8765    #[test]
8766    fn no_home_folder_refuses_a_sealed_root() {
8767        let runtime = tempfile::tempdir().unwrap();
8768        let roots = crate::runtime_dir::RuntimeDirRoots {
8769            home: None,
8770            ..test_runtime_dir(runtime.path())
8771        };
8772        let enforcement = ShellEnforcement {
8773            tier: EnforcementTier::KernelSealed,
8774            ..ShellEnforcement::environment_only().with_test_session_dirs()
8775        }
8776        .with_runtime_dir(roots, None);
8777        let error =
8778            SealedSourceFds::open(&enforcement, temp_dir_fd_of(&enforcement).as_ref(), None)
8779                .err()
8780                .expect("refused");
8781        assert!(error.contains("no home folder"), "{error}");
8782        assert!(!runtime
8783            .path()
8784            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
8785            .join("passwd")
8786            .exists());
8787    }
8788
8789    /// The synthetic account databases, written into `sealed-etc/` beside the runtime directory
8790    /// and opened by the same parent-side call.
8791    ///
8792    /// Like the temporary-folder test above it proves nothing about kernel enforcement — that is
8793    /// the hand-run in `docs/content/reference/sealed-containment-manual-verification.md`. It
8794    /// catches that the files exist before `build_sealed_root` binds them, that there is an fd to
8795    /// build each rule from, and that nothing is written into either workdir for them.
8796    #[test]
8797    fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
8798        let accessible = tempfile::tempdir().unwrap();
8799        let runtime = tempfile::tempdir().unwrap();
8800        let enforcement = sealed_with_runtime_dir(runtime.path());
8801        let sources = SealedSourceFds::open(
8802            &enforcement,
8803            temp_dir_fd_of(&enforcement).as_ref(),
8804            home_dir_fd_of(&enforcement).as_ref(),
8805        )
8806        .unwrap();
8807        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
8808            sealed: Some(&sources),
8809            ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
8810        })
8811        .expect("a real, writable workdir must yield sealed Landlock fds");
8812
8813        let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
8814            .iter()
8815            .filter(|entry| entry.synthetic.is_some())
8816            .collect();
8817        assert_eq!(
8818            fds.sealed_identity_fd_count(),
8819            synthetic.len(),
8820            "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
8821        );
8822        let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
8823        for entry in &synthetic {
8824            let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
8825            assert!(
8826                path.is_file(),
8827                "{} must be written into sealed-etc/",
8828                entry.path
8829            );
8830        }
8831        assert!(
8832            !accessible.path().join(".mur-etc").exists(),
8833            "the work folder gained .mur-etc"
8834        );
8835
8836        let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
8837            accessible.path(),
8838        ))
8839        .unwrap();
8840        assert_eq!(fds.sealed_identity_fd_count(), 0);
8841    }
8842
8843    /// A sealed root cannot be composed without the session's runtime directory: the refusal is
8844    /// the parent's, before anything is spawned.
8845    #[test]
8846    fn a_sealed_root_needs_the_runtime_directory() {
8847        let enforcement = ShellEnforcement {
8848            tier: EnforcementTier::KernelSealed,
8849            ..ShellEnforcement::environment_only()
8850        };
8851        let error = SealedSourceFds::open(&enforcement, None, None)
8852            .err()
8853            .expect("refused");
8854        assert!(error.contains("runtime directory"), "{error}");
8855    }
8856
8857    /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
8858    ///
8859    /// Both are the session's home folder's absolute path, `<records>/home`, and this asserts the
8860    /// end result — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and `os.environ["HOME"]`,
8861    /// and a disagreement between them is silent until a tool picks the wrong one. The uid/gid
8862    /// are checked against the home folder's own owner, which is where
8863    /// `ensure_sealed_identity_files` reads the capsule's identity from.
8864    #[test]
8865    fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
8866        use std::os::unix::fs::MetadataExt;
8867
8868        let runtime = tempfile::tempdir().unwrap();
8869        let roots = test_runtime_dir(runtime.path());
8870        let home = roots.home.clone().unwrap();
8871        let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
8872        let env = crate::shell::build_shell_env(
8873            &crate::types::CapabilityPolicy::default(),
8874            &[],
8875            Some(home.path()),
8876            None,
8877            None,
8878            None,
8879        )
8880        .unwrap();
8881
8882        let passwd = std::fs::read_to_string(&written[0]).unwrap();
8883        let owner = std::fs::metadata(home.path()).unwrap();
8884        let entry: Vec<&str> = passwd
8885            .lines()
8886            .last()
8887            .expect("the capsule's own entry is the last line")
8888            .split(':')
8889            .collect();
8890
8891        assert_eq!(entry[2], owner.uid().to_string(), "uid field");
8892        assert_eq!(entry[3], owner.gid().to_string(), "gid field");
8893        assert_eq!(
8894            Some(&entry[5].to_string()),
8895            env.get("HOME"),
8896            "pw_dir and $HOME are the same string or the entry is worse than useless",
8897        );
8898        assert_eq!(
8899            entry[5],
8900            runtime
8901                .path()
8902                .join(crate::records_dir::RECORDS_DIR_NAME)
8903                .join(crate::records_dir::HOME_SUBDIR)
8904                .to_str()
8905                .unwrap()
8906        );
8907
8908        let group = std::fs::read_to_string(&written[1]).unwrap();
8909        assert_eq!(
8910            group.lines().last().unwrap().split(':').nth(2),
8911            Some(owner.gid().to_string().as_str()),
8912            "gid field of the capsule's own group",
8913        );
8914    }
8915
8916    /// A workdir root over `work/` beside an `outside/` directory holding one sentinel file.
8917    fn sealed_planted() -> (
8918        tempfile::TempDir,
8919        PathBuf,
8920        PathBuf,
8921        crate::workdir_fs::WorkdirRoot,
8922    ) {
8923        let dir = tempfile::tempdir().unwrap();
8924        let accessible = dir.path().join("work");
8925        let outside = dir.path().join("outside");
8926        std::fs::create_dir_all(&accessible).unwrap();
8927        std::fs::create_dir_all(&outside).unwrap();
8928        std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
8929        let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
8930        (dir, root.path().to_path_buf(), outside, root)
8931    }
8932
8933    /// Staging twice rewrites `sealed-etc/passwd` in place: the inode a Landlock rule was bound to
8934    /// on the first call is the file the second call leaves.
8935    #[test]
8936    fn sealed_identity_files_keep_their_inode() {
8937        use std::os::unix::fs::MetadataExt;
8938
8939        let (_dir, _session, _outside, root) = sealed_planted();
8940        let runtime = tempfile::tempdir().unwrap();
8941        let roots = test_runtime_dir(runtime.path());
8942        let passwd = roots.sealed_etc.path().join("passwd");
8943        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
8944        let first = std::fs::metadata(&passwd).unwrap().ino();
8945        ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
8946        assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
8947        assert!(root.take_refusals().is_empty());
8948    }
8949
8950    /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
8951    /// no fork, no spawn.
8952    ///
8953    /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
8954    /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
8955    /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
8956    /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
8957    /// manager creating build directories — and the `Execute` axis, which must track
8958    /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
8959    /// the same storage and a binary staged in one is the same bytes as a binary staged in the
8960    /// other.
8961    #[test]
8962    fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
8963        use landlock::AccessFs;
8964
8965        for workdir_exec in [false, true] {
8966            let rights = linux_enforce::workdir_access_rights(workdir_exec);
8967
8968            for needed in [
8969                AccessFs::WriteFile,
8970                AccessFs::ReadFile,
8971                AccessFs::ReadDir,
8972                AccessFs::MakeReg,
8973                AccessFs::MakeDir,
8974                AccessFs::RemoveFile,
8975                AccessFs::RemoveDir,
8976            ] {
8977                assert!(
8978                    rights.contains(needed),
8979                    "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
8980                     `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
8981                );
8982            }
8983
8984            assert_eq!(
8985                rights.contains(AccessFs::Execute),
8986                workdir_exec,
8987                "/tmp is backed by a directory inside the workdir, so Execute there must follow \
8988                 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
8989                 make /tmp a hole through which capsule-written bytes run",
8990            );
8991        }
8992
8993        assert_eq!(
8994            linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
8995            linux_enforce::workdir_access_rights(false),
8996            "the /tmp rule and the workdir rule share one right-set binding in \
8997             apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
8998             that binding must be revisited together",
8999        );
9000    }
9001
9002    #[test]
9003    fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
9004        let mut command = std::process::Command::new("true");
9005        let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
9006        let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
9007            .expect("environment_only must never fail");
9008        assert!(matches!(supervisor, SupervisorHandle::Noop));
9009        assert_eq!(
9010            enforcement.session_netns.creations(),
9011            0,
9012            "a tier with no kernel sandbox must not create a network namespace"
9013        );
9014    }
9015
9016    // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
9017    // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
9018    // test passes while exercising a path that enforces nothing. The two tests below assert the
9019    // opposite direction — that allowlisted binaries still run — which any tier does evidence.
9020
9021    #[test]
9022    fn kernel_tier_allows_exec_within_shell_allowlist() {
9023        if crate::network_namespace::skip_without_egress_namespace(
9024            "kernel_tier_allows_exec_within_shell_allowlist",
9025        ) {
9026            return;
9027        }
9028        let tier = detect_enforcement_tier();
9029        if tier == EnforcementTier::EnvironmentOnly {
9030            eprintln!(
9031                "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
9032                 EnvironmentOnly on a Linux host — degrading gracefully"
9033            );
9034            return;
9035        }
9036
9037        let temp = tempfile::tempdir().unwrap();
9038        let policy = CapabilityPolicy {
9039            shell_allow: vec!["bash".to_string()],
9040            ..CapabilityPolicy::default()
9041        };
9042        let enforcement = ShellEnforcement {
9043            tier,
9044            network_allow_ips: Vec::new(),
9045            unix_sockets_allowed: policy.unix_sockets_allowed,
9046            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9047                &resolve_exec_allowlist(&policy.shell_allow),
9048            )),
9049            ..host_bounding_base()
9050        };
9051
9052        let result = crate::shell::execute_shell(
9053            "bash",
9054            &["-c", "exit 7"],
9055            &[],
9056            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9057            &policy,
9058            &enforcement,
9059        )
9060        .unwrap();
9061
9062        assert_eq!(
9063            result.exit_code, 7,
9064            "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
9065             proving the allowlist isn't a blanket deny"
9066        );
9067    }
9068
9069    #[test]
9070    fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
9071        if crate::network_namespace::skip_without_egress_namespace(
9072            "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
9073        ) {
9074            return;
9075        }
9076        let tier = detect_enforcement_tier();
9077        if tier == EnforcementTier::EnvironmentOnly {
9078            eprintln!(
9079                "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
9080                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9081            );
9082            return;
9083        }
9084
9085        let temp = tempfile::tempdir().unwrap();
9086        let policy = CapabilityPolicy {
9087            shell_allow: vec!["bash".to_string(), "ls".to_string()],
9088            ..CapabilityPolicy::default()
9089        };
9090        let enforcement = ShellEnforcement {
9091            tier,
9092            network_allow_ips: Vec::new(),
9093            unix_sockets_allowed: policy.unix_sockets_allowed,
9094            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9095                &resolve_exec_allowlist(&policy.shell_allow),
9096            )),
9097            ..host_bounding_base()
9098        };
9099
9100        // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
9101        // bash — the case the no-regression scenario ("allowlisted binary still functions")
9102        // actually cares about.
9103        let result = crate::shell::execute_shell(
9104            "bash",
9105            &["-c", "ls"],
9106            &[],
9107            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9108            &policy,
9109            &enforcement,
9110        )
9111        .unwrap();
9112
9113        assert_eq!(
9114            result.exit_code, 0,
9115            "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
9116             (stderr: {})",
9117            result.stderr
9118        );
9119    }
9120
9121    #[test]
9122    fn kernel_tier_denies_network_connect_outside_allowlist() {
9123        if crate::network_namespace::skip_without_egress_namespace(
9124            "kernel_tier_denies_network_connect_outside_allowlist",
9125        ) {
9126            return;
9127        }
9128        let tier = detect_enforcement_tier();
9129        if tier == EnforcementTier::EnvironmentOnly {
9130            eprintln!(
9131                "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
9132                 EnvironmentOnly on a Linux host — degrading gracefully"
9133            );
9134            return;
9135        }
9136
9137        // A real listener on the *host*, so the destination port is genuinely open. The failure
9138        // below is therefore about reachability, not about a closed port: the subprocess runs in
9139        // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
9140        // listener does not exist at all.
9141        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9142        let port = listener.local_addr().unwrap().port();
9143
9144        let temp = tempfile::tempdir().unwrap();
9145        let policy = CapabilityPolicy {
9146            shell_allow: vec!["bash".to_string()],
9147            ..CapabilityPolicy::default()
9148        };
9149        let enforcement = ShellEnforcement {
9150            tier,
9151            // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
9152            // capsule found its way to the endpoint.
9153            network_allow_rules: Vec::new(),
9154            network_allow_ips: Vec::new(),
9155            unix_sockets_allowed: policy.unix_sockets_allowed,
9156            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9157                &resolve_exec_allowlist(&policy.shell_allow),
9158            )),
9159            ..host_bounding_base()
9160        };
9161
9162        // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
9163        let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
9164        let result = crate::shell::execute_shell(
9165            "bash",
9166            &["-c", &script],
9167            &[],
9168            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9169            &policy,
9170            &enforcement,
9171        )
9172        .unwrap();
9173
9174        assert_ne!(
9175            result.exit_code, 0,
9176            "a direct connect() to a destination outside capabilities.network.allow must fail \
9177             even though the port is really open on the host — the subprocess is in its own \
9178             network namespace with no route to it"
9179        );
9180    }
9181
9182    /// The no-regression counterpart of the test above: enforcement must add denials for
9183    /// out-of-policy destinations without breaking permitted ones.
9184    ///
9185    /// The allowlist has to be declared rather than only pre-resolved, because it decides two
9186    /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
9187    /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
9188    ///
9189    /// Deliberately *not* a test of the security property — a green suite proves nothing about
9190    /// the kernel-level claim, and the real check is the hand-run procedure in
9191    /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
9192    /// asserts only that the permitted path still functions.
9193    #[test]
9194    fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
9195        if crate::network_namespace::skip_without_egress_namespace(
9196            "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
9197        ) {
9198            return;
9199        }
9200        let tier = detect_enforcement_tier();
9201        if tier == EnforcementTier::EnvironmentOnly {
9202            eprintln!(
9203                "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
9204                 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9205            );
9206            return;
9207        }
9208
9209        // A real listener on the host, answering one connection, so a success below means bytes
9210        // really crossed the namespace boundary through the proxy rather than merely connecting.
9211        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9212        let port = listener.local_addr().unwrap().port();
9213        let server = std::thread::spawn(move || {
9214            if let Ok((mut stream, _)) = listener.accept() {
9215                use std::io::Write;
9216                let _ = stream.write_all(b"pong\n");
9217            }
9218        });
9219
9220        let temp = tempfile::tempdir().unwrap();
9221        // Naming the port is required, not incidental: it is what puts a listener on that port
9222        // inside the namespace. See `egress_proxy::egress_listen_ports`.
9223        let allow = vec![format!("http://127.0.0.1:{port}")];
9224        let policy = CapabilityPolicy {
9225            shell_allow: vec!["bash".to_string()],
9226            network_allow: allow.clone(),
9227            ..CapabilityPolicy::default()
9228        };
9229        let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
9230        let enforcement = ShellEnforcement {
9231            tier,
9232            egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
9233            network_allow_rules: rules,
9234            network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
9235            unix_sockets_allowed: policy.unix_sockets_allowed,
9236            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9237                &resolve_exec_allowlist(&policy.shell_allow),
9238            )),
9239            ..host_bounding_base()
9240        };
9241
9242        // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
9243        // and this measures the network path alone.
9244        let script =
9245            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
9246        let result = crate::shell::execute_shell(
9247            "bash",
9248            &["-c", &script],
9249            &[],
9250            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9251            &policy,
9252            &enforcement,
9253        )
9254        .unwrap();
9255        let _ = server.join();
9256
9257        assert_eq!(
9258            result.exit_code, 0,
9259            "a destination inside capabilities.network.allow must stay reachable — enforcement \
9260             adds denials for out-of-policy destinations, it does not degrade permitted ones \
9261             (stderr: {})",
9262            result.stderr
9263        );
9264        assert!(
9265            result.stdout.contains("pong"),
9266            "the host listener's bytes must come back through the proxy unmodified \
9267             (stdout: {:?}, stderr: {})",
9268            result.stdout,
9269            result.stderr
9270        );
9271    }
9272
9273    #[test]
9274    fn kernel_full_denies_filesystem_access_outside_workdir() {
9275        if crate::network_namespace::skip_without_egress_namespace(
9276            "kernel_full_denies_filesystem_access_outside_workdir",
9277        ) {
9278            return;
9279        }
9280        let tier = detect_enforcement_tier();
9281        if tier != EnforcementTier::KernelFull {
9282            eprintln!(
9283                "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
9284                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
9285                 instead"
9286            );
9287            return;
9288        }
9289
9290        let temp = tempfile::tempdir().unwrap();
9291        let policy = CapabilityPolicy {
9292            shell_allow: vec!["bash".to_string()],
9293            ..CapabilityPolicy::default()
9294        };
9295        let enforcement = ShellEnforcement {
9296            tier,
9297            network_allow_ips: Vec::new(),
9298            unix_sockets_allowed: policy.unix_sockets_allowed,
9299            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9300                &resolve_exec_allowlist(&policy.shell_allow),
9301            )),
9302            ..host_bounding_base()
9303        };
9304
9305        let result = crate::shell::execute_shell(
9306            "bash",
9307            &["-c", "cat /etc/hostname"],
9308            &[],
9309            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9310            &policy,
9311            &enforcement,
9312        )
9313        .unwrap();
9314
9315        assert_ne!(
9316            result.exit_code, 0,
9317            "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
9318        );
9319    }
9320
9321    // ---- moves, links and truncation under the ruleset ABI ----
9322    //
9323    // The tests below resolve their enforcement the way a session does — `ShellEnforcement::resolve`
9324    // over a live `HostProbe` at `scoped` — so the ruleset ABI they run under is the one the probe
9325    // fills. Each one that needs a newer ABI than this host offers prints a skip line and returns.
9326
9327    /// A `scoped` session's enforcement for `policy`, from a live probe, or `None` (after a skip
9328    /// line naming `test`) when this host cannot install a Landlock domain at `min_abi` or above.
9329    fn scoped_landlock_enforcement(
9330        test: &str,
9331        policy: &CapabilityPolicy,
9332        min_abi: u8,
9333    ) -> Option<ShellEnforcement> {
9334        if crate::network_namespace::skip_without_egress_namespace(test) {
9335            return None;
9336        }
9337        let probe = HostProbe::probe();
9338        let enforcement = ShellEnforcement::resolve_declared(
9339            policy,
9340            murmur_artifact::ContainmentClass::Scoped,
9341            probe.clone(),
9342        )
9343        .unwrap()
9344        .with_test_session_dirs();
9345        if enforcement.tier != EnforcementTier::KernelFull
9346            || enforcement.landlock_abi < Some(min_abi)
9347        {
9348            eprintln!(
9349                "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
9350                 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
9351                enforcement.tier,
9352                enforcement.landlock_abi,
9353                probe.landlock_kernel_abi()
9354            );
9355            return None;
9356        }
9357        Some(enforcement)
9358    }
9359
9360    fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
9361        let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
9362            .iter()
9363            .map(|name| name.to_string())
9364            .collect();
9365        shell_allow.extend(extra.iter().map(|name| name.to_string()));
9366        CapabilityPolicy {
9367            shell_allow,
9368            ..CapabilityPolicy::default()
9369        }
9370    }
9371
9372    fn run_bash_in(
9373        workdir: &Path,
9374        script: &str,
9375        policy: &CapabilityPolicy,
9376        enforcement: &ShellEnforcement,
9377    ) -> crate::shell::ShellResult {
9378        let result = crate::shell::execute_shell(
9379            "bash",
9380            &["-c", script],
9381            &[],
9382            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
9383            policy,
9384            enforcement,
9385        )
9386        .unwrap();
9387        println!(
9388            "exit {}\n--- stdout\n{}--- stderr\n{}---",
9389            result.exit_code, result.stdout, result.stderr
9390        );
9391        result
9392    }
9393
9394    /// A `<base>/work` workdir with a sibling `<base>/outside` on the same filesystem, so a
9395    /// refusal between them is Landlock's and never the VFS's cross-mount `EXDEV`.
9396    fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
9397        let base = tempfile::tempdir().unwrap();
9398        let work = base.path().join("work");
9399        let outside = base.path().join("outside");
9400        std::fs::create_dir(&work).unwrap();
9401        std::fs::create_dir(&outside).unwrap();
9402        (base, work, outside)
9403    }
9404
9405    /// With `Refer` granted on the workdir, `mv` between two of its folders is a real `rename(2)`
9406    /// (the inode survives), and `ln` and a directory reparent across folders both succeed.
9407    #[test]
9408    fn kernel_tier_moves_files_between_workdir_folders() {
9409        let test = "kernel_tier_moves_files_between_workdir_folders";
9410        let policy = coreutils_policy(&[]);
9411        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9412            return;
9413        };
9414        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
9415        assert_eq!(enforcement.landlock_abi, Some(3));
9416
9417        let temp = tempfile::tempdir().unwrap();
9418        let result = run_bash_in(
9419            temp.path(),
9420            r#"set -e
9421mkdir a b
9422echo payload > a/x
9423before=$(stat -c %i a/x)
9424mv a/x b/x
9425after=$(stat -c %i b/x)
9426echo "mv a/x b/x: inode $before -> $after"
9427[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
9428ln b/x a/y
9429[ "$(cat a/y)" = payload ] || exit 11
9430echo "ln b/x a/y: ok"
9431mkdir a/d
9432mv a/d b/d
9433[ -d b/d ] && [ ! -e a/d ] || exit 12
9434echo "mv a/d b/d: ok"
9435"#,
9436            &policy,
9437            &enforcement,
9438        );
9439
9440        assert_eq!(
9441            result.exit_code, 0,
9442            "a move, a hard link and a directory reparent between workdir folders must all \
9443             succeed under Refer"
9444        );
9445    }
9446
9447    /// On a Landlock tier the session's home folder is `$HOME` and carries the work folder's
9448    /// rights: a command writes, renames within and removes files under it. The records directory
9449    /// around it stays read-only: a command cannot write `out/result.txt`, create `children/x`, or
9450    /// move the home folder itself aside. Both directories sit under the host's `/tmp` here, so
9451    /// where `/tmp` is mapped this also proves the home folder is re-exposed writable over the
9452    /// read-only records directory.
9453    #[test]
9454    fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
9455        let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
9456        let policy = coreutils_policy(&["rm", "rmdir"]);
9457        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9458            return;
9459        };
9460        eprintln!(
9461            "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
9462            enforcement.tier,
9463            enforcement.landlock_abi,
9464            HostProbe::probe().landlock_kernel_abi(),
9465            enforcement.installed_temp_mapping()
9466        );
9467        let records = enforcement.records_root().unwrap().clone();
9468        records
9469            .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
9470            .unwrap();
9471        let home = enforcement.home_dir_path().unwrap().to_path_buf();
9472
9473        let workdir = tempfile::tempdir().unwrap();
9474        let result = run_bash_in(
9475            workdir.path(),
9476            &format!(
9477                r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
9478echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
9479  && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
9480if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
9481if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
9482if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
9483"#,
9484                home = home.display()
9485            ),
9486            &policy,
9487            &enforcement,
9488        );
9489
9490        assert_eq!(
9491            result.stdout,
9492            "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
9493            "stderr: {}",
9494            result.stderr
9495        );
9496        assert!(home.is_dir());
9497        assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
9498        assert!(!records.host_path("out/result.txt").exists());
9499        assert!(!records.host_path("children").exists());
9500        assert!(!home.with_extension("moved").exists());
9501        assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
9502    }
9503
9504    /// A delegated child's records, laid out where staging puts them — its records group,
9505    /// `records_group(<records dir>/children)`, beside the session's own directories — are out of
9506    /// reach of a parent's command: it can neither list the child's records directory nor read its
9507    /// result, while the runtime directory reads in the same command. The parent's runtime reads
9508    /// the child's answer and hands it over instead.
9509    ///
9510    /// The refusal is `Permission denied` where Landlock refuses the path, and `No such file or
9511    /// directory` where the command's private `/tmp` does not re-expose it.
9512    #[test]
9513    fn children_records_are_not_reachable_by_a_parents_commands() {
9514        let test = "children_records_are_not_reachable_by_a_parents_commands";
9515        let policy = coreutils_policy(&["ls"]);
9516        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9517            return;
9518        };
9519        let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
9520        std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
9521        let base = tempfile::tempdir().unwrap();
9522        let child_records = base
9523            .path()
9524            .join("records/children/worker-0123456789abcdef/ses_child");
9525        let result_file = child_records.join("out/result.txt");
9526        std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
9527        std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
9528
9529        let workdir = tempfile::tempdir().unwrap();
9530        let result = run_bash_in(
9531            workdir.path(),
9532            &format!(
9533                r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
9534if ls '{dir}'; then echo listed; else echo list-refused; fi
9535if cat '{file}'; then echo read; else echo read-refused; fi
9536"#,
9537                dir = child_records.display(),
9538                file = result_file.display(),
9539            ),
9540            &policy,
9541            &enforcement,
9542        );
9543
9544        assert_eq!(
9545            result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
9546            "stderr: {}",
9547            result.stderr
9548        );
9549        assert!(
9550            !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
9551            "the child's answer reached the parent's command: {result:?}"
9552        );
9553        for line in result.stderr.lines() {
9554            assert!(
9555                line.contains("Permission denied") || line.contains("No such file or directory"),
9556                "an unexpected refusal: {}",
9557                result.stderr
9558            );
9559        }
9560        assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
9561    }
9562
9563    /// The ruleset a 5.13–5.18 kernel gets, forced through the same `landlock_abi` field the probe
9564    /// fills: the capsule still launches, `ln` across folders is `EXDEV`, and `mv` survives only
9565    /// by copying.
9566    #[test]
9567    fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
9568        let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
9569        let policy = coreutils_policy(&[]);
9570        let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9571            return;
9572        };
9573        enforcement.landlock_abi = Some(1);
9574
9575        let temp = tempfile::tempdir().unwrap();
9576        let result = run_bash_in(
9577            temp.path(),
9578            r#"mkdir a b || exit 20
9579echo payload > a/x || exit 21
9580before=$(stat -c %i a/x)
9581mv a/x b/x || exit 22
9582[ -f b/x ] && [ ! -e a/x ] || exit 23
9583after=$(stat -c %i b/x)
9584echo "mv a/x b/x: inode $before -> $after"
9585[ "$before" != "$after" ] || exit 24
9586if ln b/x a/y; then exit 25; fi
9587exit 42
9588"#,
9589            &policy,
9590            &enforcement,
9591        );
9592
9593        assert_eq!(
9594            result.exit_code, 42,
9595            "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
9596             back to copy-and-delete (a new inode) and ln fails"
9597        );
9598        assert!(
9599            result.stderr.contains("Invalid cross-device link"),
9600            "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
9601            result.stderr
9602        );
9603    }
9604
9605    /// Nothing is moved or linked out of the workdir into a sibling folder on the same filesystem.
9606    #[test]
9607    fn kernel_tier_refuses_rename_out_of_the_workdir() {
9608        let test = "kernel_tier_refuses_rename_out_of_the_workdir";
9609        let policy = coreutils_policy(&[]);
9610        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9611            return;
9612        };
9613
9614        let (_base, work, outside) = work_and_outside();
9615        let script = format!(
9616            r#"echo payload > x || exit 20
9617if mv x '{outside}/x'; then exit 21; fi
9618if ln x '{outside}/x2'; then exit 22; fi
9619exit 0
9620"#,
9621            outside = outside.display()
9622        );
9623        let result = run_bash_in(&work, &script, &policy, &enforcement);
9624
9625        assert_eq!(
9626            result.exit_code, 0,
9627            "mv and ln out of the workdir must both fail"
9628        );
9629        assert!(
9630            work.join("x").exists(),
9631            "the source must stay in the workdir"
9632        );
9633        assert!(!outside.join("x").exists(), "nothing may be moved out");
9634        assert!(!outside.join("x2").exists(), "nothing may be linked out");
9635    }
9636
9637    /// Nothing is moved or linked into the workdir from a sibling folder on the same filesystem.
9638    #[test]
9639    fn kernel_tier_refuses_rename_into_the_workdir() {
9640        let test = "kernel_tier_refuses_rename_into_the_workdir";
9641        let policy = coreutils_policy(&[]);
9642        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9643            return;
9644        };
9645
9646        let (_base, work, outside) = work_and_outside();
9647        std::fs::write(outside.join("y"), "original\n").unwrap();
9648        let script = format!(
9649            r#"if mv '{outside}/y' ./y; then exit 21; fi
9650if ln '{outside}/y' ./y2; then exit 22; fi
9651exit 0
9652"#,
9653            outside = outside.display()
9654        );
9655        let result = run_bash_in(&work, &script, &policy, &enforcement);
9656
9657        assert_eq!(
9658            result.exit_code, 0,
9659            "mv and ln into the workdir must both fail"
9660        );
9661        assert_eq!(
9662            std::fs::read_to_string(outside.join("y")).unwrap(),
9663            "original\n",
9664            "the outside file must be untouched"
9665        );
9666        assert!(!work.join("y").exists(), "nothing may be moved in");
9667        assert!(!work.join("y2").exists(), "nothing may be linked in");
9668    }
9669
9670    /// A by-path `truncate(2)` on a host file outside the workdir: refused once the ruleset
9671    /// handles `Truncate` (ABI v3), and not mediated at all at ABI v1 — the gap v3 closes.
9672    #[test]
9673    fn kernel_tier_truncate_outside_the_workdir_is_refused() {
9674        use std::os::unix::fs::PermissionsExt;
9675
9676        let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
9677        let policy = coreutils_policy(&["perl"]);
9678        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9679            return;
9680        };
9681
9682        let (_base, work, outside) = work_and_outside();
9683        let target = outside.join("t");
9684        std::fs::write(&target, "keep\n").unwrap();
9685        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
9686        let script = format!(
9687            r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
9688            target.display()
9689        );
9690
9691        let refused = run_bash_in(&work, &script, &policy, &enforcement);
9692        assert_ne!(
9693            refused.exit_code, 0,
9694            "truncate(2) outside the workdir must be refused at ABI 3"
9695        );
9696        assert!(
9697            refused.stderr.contains("Permission denied"),
9698            "the refusal must be EACCES; stderr: {}",
9699            refused.stderr
9700        );
9701        assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
9702
9703        let mut at_v1 = enforcement.clone();
9704        at_v1.landlock_abi = Some(1);
9705        let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
9706        assert_eq!(
9707            unmediated.exit_code, 0,
9708            "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
9709        );
9710        assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
9711    }
9712
9713    /// Handling `Truncate` must not refuse what a capsule truncates legitimately: its own files,
9714    /// with `truncate` and with a `>` redirect over an existing file, and `/dev/null`.
9715    #[test]
9716    fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
9717        let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
9718        let policy = coreutils_policy(&["truncate"]);
9719        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9720            return;
9721        };
9722
9723        let temp = tempfile::tempdir().unwrap();
9724        let result = run_bash_in(
9725            temp.path(),
9726            r#"set -e
9727echo hello > f && truncate -s 0 f && [ ! -s f ]
9728echo again > f && echo over > f && [ "$(cat f)" = over ]
9729: > g
9730echo discard > /dev/null
9731echo ok
9732"#,
9733            &policy,
9734            &enforcement,
9735        );
9736
9737        assert_eq!(
9738            result.exit_code, 0,
9739            "truncating inside the workdir and writing to /dev/null must keep working"
9740        );
9741    }
9742
9743    /// `uv venv` then `uv pip install <wheel>` in the workdir, offline: uv unpacks the wheel into
9744    /// a temporary folder in its cache and renames it into place. Passes at the probed ABI, and
9745    /// fails with `EXDEV` at ABI v1, so the pass is `Refer`'s.
9746    ///
9747    /// Ignored because it needs two host-side inputs a stock host does not have:
9748    /// `MURMUR_TEST_UV`, an absolute path to a `uv` binary, and `MURMUR_TEST_WHEEL`, an absolute
9749    /// path to `six-1.16.0-py2.py3-none-any.whl`, whose module and version the script checks after
9750    /// installing it. Run without them it
9751    /// panics naming the missing variable. `uv`, `python3` and its stdlib are granted by this
9752    /// test's policy only.
9753    #[test]
9754    #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
9755    fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
9756        let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
9757        let input = |name: &str| {
9758            let value = std::env::var(name)
9759                .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
9760            let path = PathBuf::from(value);
9761            assert!(
9762                path.is_absolute() && path.is_file(),
9763                "{name}={} is not a file",
9764                path.display()
9765            );
9766            path
9767        };
9768        let uv = input("MURMUR_TEST_UV");
9769        let wheel = input("MURMUR_TEST_WHEEL");
9770        let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
9771        let stdlib = std::process::Command::new(&python)
9772            .args([
9773                "-I",
9774                "-c",
9775                "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
9776            ])
9777            .output()
9778            .expect("host python3 runs");
9779        let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
9780
9781        let policy = CapabilityPolicy {
9782            shell_allow: vec![
9783                "bash".to_string(),
9784                uv.display().to_string(),
9785                python.display().to_string(),
9786            ],
9787            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
9788                binary: "python3".to_string(),
9789                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
9790                    path: stdlib,
9791                    list_dir: true,
9792                }],
9793            }],
9794            ..CapabilityPolicy::default()
9795        };
9796        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9797            return;
9798        };
9799
9800        let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
9801        let script = format!(
9802            r#"set -e
9803export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
9804'{uv}' venv --python '{python}' .venv
9805'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
9806.venv/bin/python -c 'import six; print(six.__version__)'
9807"#,
9808            uv = uv.display(),
9809            python = python.display(),
9810        );
9811        let install = |enforcement: &ShellEnforcement| {
9812            let temp = tempfile::tempdir().unwrap();
9813            std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
9814            run_bash_in(temp.path(), &script, &policy, enforcement)
9815        };
9816
9817        let at_probed = install(&enforcement);
9818        assert_eq!(
9819            at_probed.exit_code, 0,
9820            "uv must install the wheel at ABI {:?}",
9821            enforcement.landlock_abi
9822        );
9823        assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
9824
9825        let mut at_v1 = enforcement.clone();
9826        at_v1.landlock_abi = Some(1);
9827        let refused = install(&at_v1);
9828        let output = format!("{}{}", refused.stdout, refused.stderr);
9829        assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
9830        assert!(
9831            output.contains("os error 18") || output.contains("Invalid cross-device link"),
9832            "at ABI v1 the install must fail with EXDEV: {output}"
9833        );
9834    }
9835
9836    // The two tests below exercise the derived Landlock grants. A green run is not evidence they
9837    // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
9838    // no Landlock domain is installed at all. Both tests print a skip line and return when the
9839    // host is not `KernelFull`.
9840
9841    #[test]
9842    fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
9843    {
9844        if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
9845            return;
9846        }
9847        let tier = detect_enforcement_tier();
9848        if tier != EnforcementTier::KernelFull {
9849            eprintln!(
9850                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
9851                 kernel_full_runs_nontrivial_shell_allowlist_... requires \
9852                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
9853                 This run does NOT execute the derived-grant Landlock code path at all, so a \
9854                 green result here is not evidence the workdir-scope exec bug is fixed — only a \
9855                 real KernelFull Linux host can prove that."
9856            );
9857            return;
9858        }
9859
9860        let temp = tempfile::tempdir().unwrap();
9861        // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
9862        // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
9863        // Linux hosts and both live outside the workdir, so this only succeeds if the derived
9864        // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
9865        let policy = CapabilityPolicy {
9866            shell_allow: vec!["bash".to_string(), "cat".to_string()],
9867            ..CapabilityPolicy::default()
9868        };
9869        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
9870        let enforcement = ShellEnforcement {
9871            tier,
9872            network_allow_ips: Vec::new(),
9873            unix_sockets_allowed: policy.unix_sockets_allowed,
9874            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9875                &exec_allow_paths,
9876            )),
9877            ..host_bounding_base()
9878        };
9879
9880        // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
9881        // nested, dynamically-linked exec — the case the whole fix exists for.
9882        let result = crate::shell::execute_shell(
9883            "bash",
9884            &["-c", "echo landlock-grant-ok | cat"],
9885            &[],
9886            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9887            &policy,
9888            &enforcement,
9889        )
9890        .expect("execute_shell should return Ok, not Err");
9891
9892        assert_eq!(
9893            result.exit_code, 0,
9894            "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
9895             must let each exec and dynamic-link outside the workdir (stderr: {})",
9896            result.stderr
9897        );
9898        assert!(
9899            result.stdout.contains("landlock-grant-ok"),
9900            "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
9901            result.stdout,
9902            result.stderr
9903        );
9904    }
9905
9906    #[test]
9907    fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
9908        if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
9909            return;
9910        }
9911        let tier = detect_enforcement_tier();
9912        if tier != EnforcementTier::KernelFull {
9913            eprintln!(
9914                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
9915                 kernel_full_denies_write_outside_workdir_... requires \
9916                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
9917                 This run does NOT execute the Landlock code path, so a green result here is not \
9918                 evidence writes outside the workdir are actually denied — only a real KernelFull \
9919                 Linux host can prove that."
9920            );
9921            return;
9922        }
9923
9924        let workdir = tempfile::tempdir().unwrap();
9925        // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
9926        // reason a write can fail is the Landlock scope — not a missing directory.
9927        let outside = tempfile::tempdir().unwrap();
9928        let target = outside.path().join("should-not-be-written.txt");
9929
9930        let policy = CapabilityPolicy {
9931            shell_allow: vec!["bash".to_string()],
9932            ..CapabilityPolicy::default()
9933        };
9934        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
9935        let enforcement = ShellEnforcement {
9936            tier,
9937            network_allow_ips: Vec::new(),
9938            unix_sockets_allowed: policy.unix_sockets_allowed,
9939            landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9940                &exec_allow_paths,
9941            )),
9942            ..host_bounding_base()
9943        };
9944
9945        // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
9946        // write itself is what Landlock must refuse. The derived read+execute grants never widen
9947        // to write, so nothing outside the workdir (grant paths included) is writable.
9948        let script = format!("echo pwned > '{}'", target.display());
9949        let result = crate::shell::execute_shell(
9950            "bash",
9951            &["-c", &script],
9952            &[],
9953            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9954            &policy,
9955            &enforcement,
9956        )
9957        .expect("execute_shell should return Ok with a nonzero exit code, not Err");
9958
9959        assert!(
9960            !target.exists(),
9961            "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
9962        );
9963        assert_ne!(
9964            result.exit_code, 0,
9965            "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
9966        );
9967    }
9968
9969    /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
9970    /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
9971    /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
9972    fn kernel_full_enforcement(
9973        tier: EnforcementTier,
9974        policy: &CapabilityPolicy,
9975    ) -> ShellEnforcement {
9976        let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
9977        let mut landlock_grants =
9978            LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
9979        landlock_grants.extend(resolve_interpreter_runtime_grants(
9980            &policy.shell_interpreter_runtime,
9981        ));
9982        ShellEnforcement {
9983            tier,
9984            network_allow_ips: Vec::new(),
9985            unix_sockets_allowed: policy.unix_sockets_allowed,
9986            landlock_grants,
9987            ..host_bounding_base()
9988        }
9989    }
9990
9991    /// A real sealed run: the composed root's `/tmp` is the session's temporary folder in its
9992    /// records directory, `$HOME` and the synthetic `/etc/passwd`'s home are both its home folder,
9993    /// writable inside the root (`cd ~ && touch f`), the work folder gains nothing, and a delegated
9994    /// child's result beside the session's directories is not in the root at all.
9995    ///
9996    /// This drives a composed root end to end, so it needs a host that can actually reach
9997    /// `KernelSealed` and skips loudly on one that cannot; the parent-side halves are covered
9998    /// unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports the same host
9999    /// answer this checks.
10000    #[test]
10001    fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
10002        if crate::network_namespace::skip_without_egress_namespace(
10003            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
10004        ) {
10005            return;
10006        }
10007        let probe = HostProbe::probe();
10008        if probe.tier() != EnforcementTier::KernelSealed {
10009            eprintln!(
10010                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
10011                 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
10012                 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
10013                 {:?}. No composed root is built in this run.",
10014                probe.tier()
10015            );
10016            return;
10017        }
10018
10019        let accessible = tempfile::tempdir().unwrap();
10020        let policy = CapabilityPolicy {
10021            shell_allow: vec![
10022                "bash".to_string(),
10023                "cat".to_string(),
10024                "id".to_string(),
10025                "touch".to_string(),
10026                "getent".to_string(),
10027                "cut".to_string(),
10028            ],
10029            ..CapabilityPolicy::default()
10030        };
10031        let runtime = tempfile::tempdir().unwrap();
10032        let enforcement = ShellEnforcement::resolve_declared(
10033            &policy,
10034            murmur_artifact::ContainmentClass::Sealed,
10035            probe.clone(),
10036        )
10037        .expect("a sealed-capable host resolves sealed enforcement")
10038        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
10039        // A delegated child's result, beside the session's own directories as a child that has
10040        // staged leaves it, and out of the composed root's reach.
10041        let child_result = runtime
10042            .path()
10043            .join("children/worker-0123456789abcdef/out/result.txt");
10044        std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
10045        std::fs::write(&child_result, "the child's answer").unwrap();
10046        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
10047        eprintln!(
10048            "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
10049             ruleset ABI {:?} (kernel {:?})",
10050            enforcement.tier,
10051            enforcement.landlock_abi,
10052            probe.landlock_kernel_abi()
10053        );
10054        let probe_name = format!("sealed-probe-{}", std::process::id());
10055
10056        let result = crate::shell::execute_shell(
10057            "bash",
10058            &[
10059                "-c",
10060                &format!(
10061                    "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
10062                     printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
10063                     getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
10064                     {{ cat '{child}' 2>&1 || true; }}",
10065                    child = child_result.display()
10066                ),
10067            ],
10068            &[],
10069            &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
10070            &policy,
10071            &enforcement,
10072        )
10073        .expect("execute_shell returns Ok even when the command itself fails");
10074
10075        assert_eq!(
10076            result.exit_code, 0,
10077            "stdout: {} stderr: {}",
10078            result.stdout, result.stderr
10079        );
10080        let mut lines = result.stdout.lines();
10081        assert_eq!(
10082            lines.next(),
10083            Some("t"),
10084            "the capsule's /tmp must be writable"
10085        );
10086        assert!(
10087            lines.next().is_some_and(|name| !name.is_empty()),
10088            "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
10089             {}",
10090            result.stdout
10091        );
10092        assert_eq!(
10093            lines.next(),
10094            Some("/tmp"),
10095            "TMPDIR names /tmp inside the root"
10096        );
10097        let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
10098        assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
10099        assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
10100        assert_eq!(
10101            lines.next(),
10102            Some("touched"),
10103            "~ is writable inside the root"
10104        );
10105        let child_read = lines.next().unwrap_or_default();
10106        assert!(
10107            child_read.contains("No such file or directory"),
10108            "a child's result is not in the composed root: {}",
10109            result.stdout
10110        );
10111        assert!(
10112            !result.stdout.contains("the child's answer"),
10113            "{}",
10114            result.stdout
10115        );
10116        assert!(Path::new(home).join("f").is_file());
10117
10118        let folder = enforcement.temp_dir_path().unwrap();
10119        assert_eq!(
10120            std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
10121            "t\n",
10122            "the bytes written to /tmp land in the session's temporary folder"
10123        );
10124        assert!(
10125            !Path::new("/tmp").join(&probe_name).exists(),
10126            "nothing reaches the host's /tmp"
10127        );
10128        assert!(runtime
10129            .path()
10130            .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
10131            .join("passwd")
10132            .is_file());
10133        assert_eq!(
10134            std::fs::read_dir(accessible.path()).unwrap().count(),
10135            0,
10136            "enabling sealed must add nothing to the work folder"
10137        );
10138    }
10139
10140    // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
10141    // return when the host is not `KernelFull`.
10142
10143    #[test]
10144    fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
10145        if crate::network_namespace::skip_without_egress_namespace(
10146            "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
10147        ) {
10148            return;
10149        }
10150        let tier = detect_enforcement_tier();
10151        if tier != EnforcementTier::KernelFull {
10152            eprintln!(
10153                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10154                 kernel_full_interpreter_runtime_list_dir_false_... requires \
10155                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10156                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10157                 result here is not evidence list_dir:false behaves correctly — only a real \
10158                 KernelFull Linux host can prove that."
10159            );
10160            return;
10161        }
10162
10163        let workdir = tempfile::tempdir().unwrap();
10164        // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
10165        let stdlib = tempfile::tempdir().unwrap();
10166        let module = stdlib.path().join("mod.py");
10167        std::fs::write(&module, "x = 1\n").unwrap();
10168
10169        let policy = CapabilityPolicy {
10170            shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
10171            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10172                binary: "bash".to_string(),
10173                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10174                    path: stdlib.path().to_string_lossy().into_owned(),
10175                    list_dir: false,
10176                }],
10177            }],
10178            ..CapabilityPolicy::default()
10179        };
10180        let enforcement = kernel_full_enforcement(tier, &policy);
10181
10182        // Opening the file by its exact known name must succeed — Landlock's read rights apply to
10183        // the subtree beneath the granted directory.
10184        let read = crate::shell::execute_shell(
10185            "bash",
10186            &["-c", &format!("cat '{}'", module.display())],
10187            &[],
10188            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10189            &policy,
10190            &enforcement,
10191        )
10192        .expect("execute_shell should return Ok");
10193        assert_eq!(
10194            read.exit_code, 0,
10195            "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
10196            read.stderr
10197        );
10198        assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
10199
10200        // Listing the directory's own entries must fail — ReadDir was not granted.
10201        let list = crate::shell::execute_shell(
10202            "bash",
10203            &["-c", &format!("ls '{}'", stdlib.path().display())],
10204            &[],
10205            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10206            &policy,
10207            &enforcement,
10208        )
10209        .expect("execute_shell should return Ok with a nonzero exit code");
10210        assert_ne!(
10211            list.exit_code, 0,
10212            "list_dir:false must deny enumerating the granted directory's own entries"
10213        );
10214    }
10215
10216    #[test]
10217    fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
10218        if crate::network_namespace::skip_without_egress_namespace(
10219            "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
10220        ) {
10221            return;
10222        }
10223        let tier = detect_enforcement_tier();
10224        if tier != EnforcementTier::KernelFull {
10225            eprintln!(
10226                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10227                 kernel_full_interpreter_runtime_list_dir_true_... requires \
10228                 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10229                 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10230                 result here is not evidence list_dir:true (and its non-widening to the parent) \
10231                 behaves correctly — only a real KernelFull Linux host can prove that."
10232            );
10233            return;
10234        }
10235
10236        let workdir = tempfile::tempdir().unwrap();
10237        // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
10238        let parent = tempfile::tempdir().unwrap();
10239        let stdlib = parent.path().join("stdlib");
10240        std::fs::create_dir(&stdlib).unwrap();
10241        std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
10242
10243        let policy = CapabilityPolicy {
10244            shell_allow: vec!["bash".to_string(), "ls".to_string()],
10245            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10246                binary: "bash".to_string(),
10247                dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10248                    path: stdlib.to_string_lossy().into_owned(),
10249                    list_dir: true,
10250                }],
10251            }],
10252            ..CapabilityPolicy::default()
10253        };
10254        let enforcement = kernel_full_enforcement(tier, &policy);
10255
10256        // Listing the granted directory must succeed.
10257        let list = crate::shell::execute_shell(
10258            "bash",
10259            &["-c", &format!("ls '{}'", stdlib.display())],
10260            &[],
10261            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10262            &policy,
10263            &enforcement,
10264        )
10265        .expect("execute_shell should return Ok");
10266        assert_eq!(
10267            list.exit_code, 0,
10268            "list_dir:true must permit enumerating the granted directory (stderr: {})",
10269            list.stderr
10270        );
10271        assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
10272
10273        // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
10274        // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
10275        let list_parent = crate::shell::execute_shell(
10276            "bash",
10277            &["-c", &format!("ls '{}'", parent.path().display())],
10278            &[],
10279            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10280            &policy,
10281            &enforcement,
10282        )
10283        .expect("execute_shell should return Ok with a nonzero exit code");
10284        assert_ne!(
10285            list_parent.exit_code, 0,
10286            "a grant on stdlib/ must not make its (ungranted) parent enumerable"
10287        );
10288    }
10289
10290    /// The `executable` axis, asserted against a real Landlock kernel.
10291    ///
10292    /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
10293    /// readable and enumerable without becoming a place to run programs from. It is asserted here
10294    /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
10295    /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
10296    /// would have to be driven through a composed root, which no unit test can reach.
10297    ///
10298    /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
10299    /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
10300    #[test]
10301    fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
10302        if crate::network_namespace::skip_without_egress_namespace(
10303            "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
10304        ) {
10305            return;
10306        }
10307        let host_tier = detect_enforcement_tier();
10308        if !matches!(
10309            host_tier,
10310            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10311        ) {
10312            eprintln!(
10313                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10314                 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
10315                 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
10316                 result is not evidence that withholding Execute denies execve."
10317            );
10318            return;
10319        }
10320
10321        let workdir = tempfile::tempdir().unwrap();
10322        // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
10323        let tree = tempfile::tempdir().unwrap();
10324        std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
10325        let runme = tree.path().join("runme");
10326        std::fs::copy("/bin/echo", &runme).unwrap();
10327
10328        let policy = CapabilityPolicy {
10329            shell_allow: vec!["bash".to_string(), "ls".to_string()],
10330            ..CapabilityPolicy::default()
10331        };
10332        let with_tree_grant = |executable: bool| {
10333            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
10334            enforcement.landlock_grants.push(LandlockGrant {
10335                path: tree.path().to_path_buf(),
10336                list_dir: true,
10337                executable,
10338                pinned: None,
10339            });
10340            enforcement
10341        };
10342        let run = |enforcement: &ShellEnforcement, command: String| {
10343            crate::shell::execute_shell(
10344                "bash",
10345                &["-c", &command],
10346                &[],
10347                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10348                &policy,
10349                enforcement,
10350            )
10351            .expect("execute_shell should return Ok even when the command itself fails")
10352        };
10353
10354        let denied = with_tree_grant(false);
10355        let list = run(&denied, format!("ls '{}'", tree.path().display()));
10356        assert_eq!(
10357            list.exit_code, 0,
10358            "a non-executable grant must still be enumerable — that is its entire purpose \
10359             (stderr: {})",
10360            list.stderr
10361        );
10362        assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
10363
10364        let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
10365        assert_ne!(
10366            exec_denied.exit_code, 0,
10367            "withholding Execute must deny execve inside the granted tree; a program ran instead \
10368             (stdout: {})",
10369            exec_denied.stdout
10370        );
10371        assert!(
10372            !exec_denied.stdout.contains("ran"),
10373            "the binary produced output, so it executed: {}",
10374            exec_denied.stdout
10375        );
10376
10377        // The control: the identical grant with `executable: true` does run it. Without this the
10378        // assertion above could be passing for an unrelated reason (a bad path, a missing library).
10379        let allowed = with_tree_grant(true);
10380        let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
10381        assert_eq!(
10382            exec_allowed.exit_code, 0,
10383            "the same tree granted Execute must run (stderr: {})",
10384            exec_allowed.stderr
10385        );
10386        assert!(
10387            exec_allowed.stdout.contains("ran"),
10388            "stdout: {}",
10389            exec_allowed.stdout
10390        );
10391    }
10392
10393    /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
10394    /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
10395    ///
10396    /// Two kernel facts are pinned here. First, `ReadFile` alone —
10397    /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
10398    /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
10399    /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
10400    /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
10401    /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
10402    /// read, not die in `pre_exec`.
10403    ///
10404    /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
10405    #[test]
10406    fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
10407        if crate::network_namespace::skip_without_egress_namespace(
10408            "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
10409        ) {
10410            return;
10411        }
10412        let host_tier = detect_enforcement_tier();
10413        if !matches!(
10414            host_tier,
10415            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10416        ) {
10417            eprintln!(
10418                "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10419                 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
10420                 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
10421                 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
10422                 a file is narrowed instead of refusing the launch."
10423            );
10424            return;
10425        }
10426
10427        let workdir = tempfile::tempdir().unwrap();
10428        // A trust-store stand-in: a regular file outside the workdir, granted on its own.
10429        let store = tempfile::tempdir().unwrap();
10430        let bundle = store.path().join("ca-certificates.crt");
10431        std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
10432
10433        let policy = CapabilityPolicy {
10434            shell_allow: vec!["bash".to_string(), "cat".to_string()],
10435            ..CapabilityPolicy::default()
10436        };
10437        let with_file_grant = |list_dir: bool| {
10438            let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
10439            enforcement.landlock_grants.push(LandlockGrant {
10440                path: bundle.clone(),
10441                list_dir,
10442                executable: false,
10443                pinned: None,
10444            });
10445            enforcement
10446        };
10447        let read = |enforcement: &ShellEnforcement| {
10448            crate::shell::execute_shell(
10449                "bash",
10450                &["-c", &format!("cat '{}'", bundle.display())],
10451                &[],
10452                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10453                &policy,
10454                enforcement,
10455            )
10456            .expect("execute_shell should return Ok even when the command itself fails")
10457        };
10458
10459        let honest = read(&with_file_grant(false));
10460        assert_eq!(
10461            honest.exit_code, 0,
10462            "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
10463             file entries rely on (stderr: {})",
10464            honest.stderr
10465        );
10466        assert!(
10467            honest.stdout.contains("BEGIN CERTIFICATE"),
10468            "stdout: {}",
10469            honest.stdout
10470        );
10471
10472        let overclaimed = read(&with_file_grant(true));
10473        assert_eq!(
10474            overclaimed.exit_code, 0,
10475            "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
10476             into an EINVAL that refuses the launch (stderr: {})",
10477            overclaimed.stderr
10478        );
10479        assert!(
10480            overclaimed.stdout.contains("BEGIN CERTIFICATE"),
10481            "stdout: {}",
10482            overclaimed.stdout
10483        );
10484    }
10485
10486    // ---- every distinct pre_exec setup failure is legible + fail-closed ----
10487    //
10488    // These force each of the three distinct sandbox setup failures and assert each produces its
10489    // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
10490    // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
10491    // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
10492    // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
10493    // short-circuits before any real Landlock syscall.
10494
10495    struct ForceNoNewPrivsFailureGuard;
10496
10497    impl ForceNoNewPrivsFailureGuard {
10498        fn new() -> Self {
10499            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
10500            Self
10501        }
10502    }
10503
10504    impl Drop for ForceNoNewPrivsFailureGuard {
10505        fn drop(&mut self) {
10506            FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
10507        }
10508    }
10509
10510    struct ForceLandlockFailureGuard;
10511
10512    impl ForceLandlockFailureGuard {
10513        fn new() -> Self {
10514            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
10515            Self
10516        }
10517    }
10518
10519    impl Drop for ForceLandlockFailureGuard {
10520        fn drop(&mut self) {
10521            FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
10522        }
10523    }
10524
10525    /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
10526    /// depend on any specific grant — they force the failure directly — so this exercises the
10527    /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
10528    fn kernel_full_empty_grants() -> ShellEnforcement {
10529        ShellEnforcement {
10530            tier: EnforcementTier::KernelFull,
10531            network_allow_ips: Vec::new(),
10532            unix_sockets_allowed: false,
10533            landlock_grants: Vec::new(),
10534            ..host_bounding_base()
10535        }
10536    }
10537
10538    /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
10539    /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
10540    /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
10541    fn workdir_resolution_error() -> String {
10542        let temp = tempfile::tempdir().unwrap();
10543        let missing = temp.path().join("does-not-exist-workdir");
10544        let marker = temp.path().join("never-spawned.marker");
10545        let mut command = std::process::Command::new("bash");
10546        command
10547            .arg("-c")
10548            .arg(format!("echo ran > '{}'", marker.display()));
10549
10550        let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
10551            .expect_err("an unresolvable workdir must fail before fork()");
10552        assert!(
10553            !marker.exists(),
10554            "prepare_enforcement must never spawn a subprocess: {error}"
10555        );
10556        error
10557    }
10558
10559    /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
10560    /// child-side failure active, asserts the target script never ran (marker absent), and returns
10561    /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
10562    fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
10563        child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
10564    }
10565
10566    /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
10567    /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
10568    /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
10569    /// identity out to the CLI instead of arriving as one more error string.
10570    fn child_setup_failure_typed<G>(
10571        enforcement: &ShellEnforcement,
10572        arm: impl FnOnce() -> G,
10573    ) -> crate::shell::ShellExecError {
10574        let temp = tempfile::tempdir().unwrap();
10575        let policy = CapabilityPolicy {
10576            shell_allow: vec!["bash".to_string()],
10577            ..CapabilityPolicy::default()
10578        };
10579        let marker = temp.path().join("spawned-anyway.marker");
10580        let script = format!("echo ran > '{}'", marker.display());
10581
10582        let _guard = arm();
10583        let error = crate::shell::execute_shell(
10584            "bash",
10585            &["-c", &script],
10586            &[],
10587            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10588            &policy,
10589            enforcement,
10590        )
10591        .expect_err("a forced child-side setup failure must make execute_shell return Err");
10592        assert!(
10593            !marker.exists(),
10594            "no subprocess may run when pre_exec setup fails: {error}"
10595        );
10596        error
10597    }
10598
10599    #[test]
10600    fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
10601        let error = workdir_resolution_error();
10602        assert!(
10603            error.contains("workdir") && error.contains("does-not-exist-workdir"),
10604            "the error must name the workdir path that failed to resolve: {error}"
10605        );
10606        assert!(
10607            !error.contains("os error 22"),
10608            "must not collapse to the bare EINVAL string: {error}"
10609        );
10610    }
10611
10612    #[test]
10613    fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
10614        if crate::network_namespace::skip_without_egress_namespace(
10615            "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
10616        ) {
10617            return;
10618        }
10619        let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
10620        assert!(
10621            error.contains("no_new_privs"),
10622            "the error must name the no_new_privs step specifically: {error}"
10623        );
10624        assert!(
10625            !error.contains("os error 22"),
10626            "must not collapse to the bare EINVAL string: {error}"
10627        );
10628    }
10629
10630    #[test]
10631    fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
10632        if crate::network_namespace::skip_without_egress_namespace(
10633            "pre_exec_landlock_failure_is_distinct_and_fails_closed",
10634        ) {
10635            return;
10636        }
10637        let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
10638        assert!(
10639            error.contains("landlock"),
10640            "the error must name the Landlock construction step specifically: {error}"
10641        );
10642        assert!(
10643            !error.contains("os error 22"),
10644            "must not collapse to the bare EINVAL string: {error}"
10645        );
10646    }
10647
10648    #[test]
10649    fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
10650        if crate::network_namespace::skip_without_egress_namespace(
10651            "pre_exec_setup_failures_produce_pairwise_distinct_messages",
10652        ) {
10653            return;
10654        }
10655        let workdir_msg = workdir_resolution_error();
10656        let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
10657        let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
10658
10659        let messages = [
10660            ("workdir", &workdir_msg),
10661            ("no_new_privs", &no_new_privs_msg),
10662            ("landlock", &landlock_msg),
10663        ];
10664        for (i, (left_name, left)) in messages.iter().enumerate() {
10665            for (right_name, right) in &messages[i + 1..] {
10666                assert_ne!(left, right, "{left_name} vs {right_name} must differ");
10667            }
10668        }
10669
10670        for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
10671            assert!(!message.is_empty(), "a distinct message must not be empty");
10672            assert!(
10673                !message.contains("os error 22"),
10674                "no failure may read as a bare EINVAL: {message}"
10675            );
10676        }
10677    }
10678
10679    /// The first half of the composed-root failure path: a `pre_exec`
10680    /// composed-root failure comes back out of `execute_shell` as the *typed*
10681    /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
10682    /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
10683    /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
10684    #[test]
10685    fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
10686        if crate::network_namespace::skip_without_egress_namespace(
10687            "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
10688        ) {
10689            return;
10690        }
10691        let error =
10692            child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
10693
10694        assert!(
10695            matches!(
10696                error,
10697                crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
10698            ),
10699            "a sealed-root diagnostic must survive as its own variant, not collapse into \
10700             ShellExecError::Failed: {error}"
10701        );
10702        let fatal = error
10703            .session_fatal()
10704            .expect("a composed-root failure must end the session, not just the tool call");
10705        assert!(
10706            matches!(
10707                fatal,
10708                crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
10709            ),
10710            "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
10711        );
10712        let rendered = error.to_string();
10713        assert!(
10714            rendered.contains("composed root") && rendered.contains("unshare"),
10715            "the message must name both the mechanism and the step that failed: {rendered}"
10716        );
10717        assert!(
10718            !rendered.contains("os error 22"),
10719            "must not collapse to the bare EINVAL string: {rendered}"
10720        );
10721    }
10722
10723    /// The contrast that makes the assertion above mean something: an equally fatal-looking
10724    /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
10725    /// in this path ends a session for a Landlock or `no_new_privs` problem.
10726    #[test]
10727    fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
10728        let landlock =
10729            child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
10730        assert!(
10731            landlock.session_fatal().is_none(),
10732            "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
10733        );
10734
10735        let no_new_privs = child_setup_failure_typed(
10736            &kernel_full_empty_grants(),
10737            ForceNoNewPrivsFailureGuard::new,
10738        );
10739        assert!(
10740            no_new_privs.session_fatal().is_none(),
10741            "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
10742        );
10743    }
10744
10745    // ---- one network namespace per session -------------------------------------------------
10746    //
10747    // Every test below drives real commands through `execute_shell`/`run_shell` against an
10748    // enforcement resolved the way `stage_session` resolves one, and reads what a later command of
10749    // the same session — or of another session — can reach.
10750
10751    /// `bash`, `nc` and `sleep`, plus `extra`: what a model starting and probing a server uses.
10752    fn server_policy(extra: &[&str]) -> CapabilityPolicy {
10753        CapabilityPolicy {
10754            shell_allow: ["bash", "nc", "sleep"]
10755                .iter()
10756                .chain(extra)
10757                .map(|binary| binary.to_string())
10758                .collect(),
10759            ..CapabilityPolicy::default()
10760        }
10761    }
10762
10763    /// A session's enforcement, resolved as `stage_session` resolves a `scoped` capsule's.
10764    fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
10765        let enforcement = ShellEnforcement::resolve_declared(
10766            policy,
10767            murmur_artifact::ContainmentClass::Scoped,
10768            HostProbe::probe(),
10769        )
10770        .expect("the policy resolves")
10771        .with_test_session_dirs();
10772        assert_eq!(
10773            enforcement.tier,
10774            EnforcementTier::KernelFull,
10775            "a scoped capsule on this host runs on KernelFull"
10776        );
10777        enforcement
10778    }
10779
10780    /// A one-shot server on `127.0.0.1:<port>` that answers its one connection with `reply`,
10781    /// backgrounded with its output redirected so the command that starts it returns at once.
10782    fn one_shot_server(reply: &str, port: u16) -> String {
10783        format!(
10784            "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
10785        )
10786    }
10787
10788    /// Connects to `127.0.0.1:<port>`, retrying for up to five seconds while the server comes up,
10789    /// and prints the first line it reads.
10790    fn retrying_client(port: u16) -> String {
10791        format!(
10792            "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
10793             sleep 0.1; done; read -r reply <&3; echo \"$reply\""
10794        )
10795    }
10796
10797    fn bash(
10798        script: &str,
10799        workdir: &Path,
10800        policy: &CapabilityPolicy,
10801        enforcement: &ShellEnforcement,
10802    ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
10803        crate::shell::execute_shell(
10804            "bash",
10805            &["-c", script],
10806            &[],
10807            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
10808            policy,
10809            enforcement,
10810        )
10811    }
10812
10813    fn pid_in(workdir: &Path, file: &str) -> i32 {
10814        std::fs::read_to_string(workdir.join(file))
10815            .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
10816            .trim()
10817            .parse()
10818            .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
10819    }
10820
10821    fn process_exists(pid: i32) -> bool {
10822        Path::new(&format!("/proc/{pid}")).exists()
10823    }
10824
10825    /// Whether `/proc/<pid>` is gone within `timeout`. A killed process stays visible until its
10826    /// parent — `init` for an orphan — reaps it, which takes a moment after the kill itself.
10827    fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
10828        let deadline = std::time::Instant::now() + timeout;
10829        while process_exists(pid) {
10830            if std::time::Instant::now() >= deadline {
10831                return false;
10832            }
10833            std::thread::sleep(std::time::Duration::from_millis(20));
10834        }
10835        true
10836    }
10837
10838    /// A server one command backgrounds answers a later, separate command of the same session.
10839    #[test]
10840    #[allow(clippy::print_stdout)]
10841    fn a_listener_started_by_one_command_answers_the_next() {
10842        if crate::network_namespace::skip_without_egress_namespace(
10843            "a_listener_started_by_one_command_answers_the_next",
10844        ) {
10845            return;
10846        }
10847        let temp = tempfile::tempdir().unwrap();
10848        let policy = server_policy(&[]);
10849        let enforcement = session_enforcement(&policy);
10850
10851        let started = std::time::Instant::now();
10852        let server = bash(
10853            &one_shot_server("pong", 8080),
10854            temp.path(),
10855            &policy,
10856            &enforcement,
10857        )
10858        .expect("the server command runs");
10859        let server_ms = started.elapsed().as_millis();
10860        assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
10861        assert!(
10862            started.elapsed() < std::time::Duration::from_secs(5),
10863            "a backgrounded server with its output redirected must not hold the command open"
10864        );
10865
10866        let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
10867            .expect("the client command runs");
10868        println!(
10869            "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
10870             namespaces created: {}",
10871            enforcement.tier,
10872            server.exit_code,
10873            client.exit_code,
10874            client.stdout.trim(),
10875            enforcement.session_netns.creations()
10876        );
10877        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
10878        assert_eq!(client.stdout.trim(), "pong");
10879        assert_eq!(enforcement.session_netns.creations(), 1);
10880
10881        enforcement.end_session();
10882    }
10883
10884    /// A foreground server demoted after its grace period keeps listening, and a later command
10885    /// reaches it; the demoted command then completes with what it received.
10886    #[test]
10887    #[allow(clippy::print_stdout)]
10888    fn a_demoted_server_stays_reachable_from_later_commands() {
10889        use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
10890
10891        if crate::network_namespace::skip_without_egress_namespace(
10892            "a_demoted_server_stays_reachable_from_later_commands",
10893        ) {
10894            return;
10895        }
10896        let temp = tempfile::tempdir().unwrap();
10897        let base = tempfile::tempdir().unwrap();
10898        let policy = server_policy(&[]);
10899        let enforcement =
10900            session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
10901        let (registry, mut reports) = DetachedRegistry::new();
10902
10903        let outcome = crate::shell::run_shell(
10904            "nc",
10905            &["-l", "127.0.0.1", "8080"],
10906            &[],
10907            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10908            &policy,
10909            &enforcement,
10910            Some(DetachPolicy {
10911                grace: std::time::Duration::ZERO,
10912                delivered: true,
10913                registry: Arc::clone(&registry),
10914                command: "-l 127.0.0.1 8080".to_string(),
10915                context_id: "ctx_demoted_server".to_string(),
10916                provenance: None,
10917                abandoned: None,
10918                canceled: None,
10919            }),
10920        )
10921        .expect("the server command runs");
10922        let crate::shell::ShellOutcome::Detached(info) = outcome else {
10923            panic!("a zero grace period demotes the server, got {outcome:?}");
10924        };
10925
10926        let client = bash(
10927            "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
10928             sleep 0.1; done; echo ping >&3",
10929            temp.path(),
10930            &policy,
10931            &enforcement,
10932        )
10933        .expect("the client command runs");
10934        assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
10935
10936        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
10937        let completion = loop {
10938            match reports.try_recv() {
10939                Ok(DetachedReport::Completed(completion)) => break completion,
10940                Ok(DetachedReport::Lost(_)) => {}
10941                Err(_) if std::time::Instant::now() < deadline => {
10942                    std::thread::sleep(std::time::Duration::from_millis(20));
10943                }
10944                Err(error) => panic!("the demoted server never completed: {error}"),
10945            }
10946        };
10947        let log = std::fs::read_to_string(&completion.output_path)
10948            .expect("the demoted command's log is written");
10949        println!(
10950            "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
10951            info.work_id,
10952            client.exit_code,
10953            completion.work_id,
10954            completion.exit_code,
10955            log.contains("ping")
10956        );
10957        assert_eq!(completion.work_id, info.work_id);
10958        assert_eq!(completion.exit_code, 0, "log: {log}");
10959        assert!(log.contains("ping"), "log: {log}");
10960
10961        enforcement.end_session();
10962    }
10963
10964    /// A truncated command's log, named by its absolute path in the session's records directory,
10965    /// reads back under the session's own enforcement: through the shell interpreter and through
10966    /// a binary allowed on its own that gets the path as an argument. A write to it is refused,
10967    /// on any tier that installs Landlock.
10968    #[test]
10969    #[allow(clippy::print_stdout)]
10970    fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
10971        // A host that cannot give the session its network namespace refuses the session at
10972        // staging, so there is no session enforcement to run under.
10973        if crate::network_namespace::skip_without_egress_namespace(
10974            "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
10975        ) {
10976            return;
10977        }
10978        let base = home_tempdir();
10979        let workdir = home_tempdir();
10980        let policy = CapabilityPolicy {
10981            shell_allow: vec!["bash".to_string(), "grep".to_string()],
10982            ..CapabilityPolicy::default()
10983        };
10984        let enforcement = ShellEnforcement::resolve_declared(
10985            &policy,
10986            murmur_artifact::ContainmentClass::Scoped,
10987            HostProbe::probe(),
10988        )
10989        .expect("the policy resolves")
10990        .with_runtime_dir(test_runtime_dir(base.path()), None);
10991        let landlock = matches!(
10992            enforcement.tier,
10993            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10994        );
10995        println!(
10996            "tier {:?}; landlock ABI {:?}",
10997            enforcement.tier,
10998            detect_landlock_abi()
10999        );
11000
11001        // A builtin loop: only `bash` and `grep` are executable under Landlock.
11002        let printed = run_bash_in(
11003            workdir.path(),
11004            "for ((i = 1; i <= 20000; i++)); do echo $i; done",
11005            &policy,
11006            &enforcement,
11007        );
11008        assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
11009        assert!(printed.truncated);
11010        let path = printed
11011            .full_output_path
11012            .clone()
11013            .expect("a truncated command names its log");
11014        assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
11015        let before = std::fs::read(&path).unwrap();
11016
11017        let through_bash = run_bash_in(
11018            workdir.path(),
11019            &format!("grep -x 20000 {path}"),
11020            &policy,
11021            &enforcement,
11022        );
11023        assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
11024        assert_eq!(through_bash.stdout, "20000\n");
11025
11026        let through_grep = crate::shell::execute_shell(
11027            "grep",
11028            &["-x", "20000", &path],
11029            &[],
11030            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11031            &policy,
11032            &enforcement,
11033        )
11034        .unwrap();
11035        assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
11036        assert_eq!(through_grep.stdout, "20000\n");
11037
11038        if landlock {
11039            let write = run_bash_in(
11040                workdir.path(),
11041                &format!("echo x >> {path}"),
11042                &policy,
11043                &enforcement,
11044            );
11045            assert_ne!(write.exit_code, 0, "a command appended to its own log");
11046            assert_eq!(std::fs::read(&path).unwrap(), before);
11047        } else {
11048            println!(
11049                "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
11050                 the write half needs Landlock; detected {:?}",
11051                enforcement.tier
11052            );
11053        }
11054        assert!(!workdir.path().join("logs").exists());
11055        enforcement.end_session();
11056    }
11057
11058    /// Two sessions each bind the same port on their own loopback, each reaches only its own
11059    /// server, and the host reaches neither.
11060    #[test]
11061    #[allow(clippy::print_stdout)]
11062    fn two_sessions_do_not_share_loopback() {
11063        if crate::network_namespace::skip_without_egress_namespace(
11064            "two_sessions_do_not_share_loopback",
11065        ) {
11066            return;
11067        }
11068        let port = {
11069            let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11070            probe.local_addr().unwrap().port()
11071        };
11072        let policy = server_policy(&[]);
11073        let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
11074        let session_a = session_enforcement(&policy);
11075        let session_b = session_enforcement(&policy);
11076
11077        for (dir, session, reply) in [
11078            (&dir_a, &session_a, "pong-a"),
11079            (&dir_b, &session_b, "pong-b"),
11080        ] {
11081            let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
11082                .expect("the server command runs");
11083            assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11084        }
11085        // Both servers bound the same port: each is still running, waiting for its one client.
11086        std::thread::sleep(std::time::Duration::from_millis(300));
11087        for dir in [&dir_a, &dir_b] {
11088            let pid = pid_in(dir.path(), "server.pid");
11089            assert!(process_exists(pid), "a server's bind on {port} failed");
11090        }
11091
11092        let host = std::net::TcpStream::connect(("127.0.0.1", port));
11093        let host_error = host.expect_err("the host must not reach a session's loopback server");
11094        assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
11095
11096        let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
11097            .expect("B's client runs");
11098        assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
11099
11100        // B's one-shot server is spent; A's still listens on the same port in A's namespace.
11101        let second_b = bash(
11102            &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
11103            dir_b.path(),
11104            &policy,
11105            &session_b,
11106        )
11107        .expect("B's second client runs");
11108
11109        let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
11110            .expect("A's client runs");
11111        println!(
11112            "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
11113             stderr {:?}; A read {:?}",
11114            client_b.stdout.trim(),
11115            second_b.exit_code,
11116            second_b.stderr.trim(),
11117            client_a.stdout.trim()
11118        );
11119        assert_eq!(client_b.stdout.trim(), "pong-b");
11120        assert_ne!(second_b.exit_code, 0);
11121        assert!(
11122            second_b.stderr.contains("Connection refused"),
11123            "stderr: {}",
11124            second_b.stderr
11125        );
11126        assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
11127        assert_eq!(client_a.stdout.trim(), "pong-a");
11128
11129        session_a.end_session();
11130        session_b.end_session();
11131    }
11132
11133    /// The session's one egress proxy serves every command, refuses what is not allowlisted,
11134    /// and is created once.
11135    #[test]
11136    #[allow(clippy::print_stdout)]
11137    fn the_session_proxy_serves_every_command() {
11138        if crate::network_namespace::skip_without_egress_namespace(
11139            "the_session_proxy_serves_every_command",
11140        ) {
11141            return;
11142        }
11143        // Answers every connection, so the first and third commands both get a reply.
11144        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11145        let port = listener.local_addr().unwrap().port();
11146        std::thread::spawn(move || {
11147            for stream in listener.incoming() {
11148                use std::io::Write;
11149                if let Ok(mut stream) = stream {
11150                    let _ = stream.write_all(b"pong\n");
11151                }
11152            }
11153        });
11154
11155        let temp = tempfile::tempdir().unwrap();
11156        let policy = CapabilityPolicy {
11157            network_allow: vec![format!("http://127.0.0.1:{port}")],
11158            ..server_policy(&[])
11159        };
11160        let enforcement = session_enforcement(&policy);
11161        let through_proxy =
11162            format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
11163
11164        let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11165        let unlisted = bash(
11166            "exec 3<>/dev/tcp/10.255.255.1/9",
11167            temp.path(),
11168            &policy,
11169            &enforcement,
11170        )
11171        .unwrap();
11172        let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11173        println!(
11174            "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
11175             created: {}",
11176            first.stdout.trim(),
11177            first.exit_code,
11178            unlisted.exit_code,
11179            unlisted.stderr.trim(),
11180            third.stdout.trim(),
11181            third.exit_code,
11182            enforcement.session_netns.creations()
11183        );
11184        assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
11185        assert_eq!(first.stdout.trim(), "pong");
11186        assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
11187        assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
11188        assert_eq!(third.stdout.trim(), "pong");
11189        assert_eq!(enforcement.session_netns.creations(), 1);
11190
11191        enforcement.end_session();
11192    }
11193
11194    /// Commands whose first spawns race each other still create exactly one namespace, and
11195    /// all of them land in it.
11196    #[test]
11197    #[allow(clippy::print_stdout)]
11198    fn concurrent_first_commands_share_one_namespace() {
11199        if crate::network_namespace::skip_without_egress_namespace(
11200            "concurrent_first_commands_share_one_namespace",
11201        ) {
11202            return;
11203        }
11204        let temp = tempfile::tempdir().unwrap();
11205        let policy = server_policy(&[]);
11206        let enforcement = session_enforcement(&policy);
11207        // One one-shot server per client, on its own port: a second client queued on a one-shot
11208        // `nc -l` would sit in its listen backlog and be closed unanswered when it exits.
11209        let servers = (8081..=8083)
11210            .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
11211            .collect::<Vec<_>>()
11212            .join("; ");
11213        let scripts = [
11214            servers,
11215            retrying_client(8081),
11216            retrying_client(8082),
11217            retrying_client(8083),
11218        ];
11219
11220        let barrier = std::sync::Barrier::new(scripts.len());
11221        let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
11222            let handles: Vec<_> = scripts
11223                .iter()
11224                .map(|script| {
11225                    let enforcement = enforcement.clone();
11226                    let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
11227                    scope.spawn(move || {
11228                        barrier.wait();
11229                        bash(script, workdir, policy, &enforcement).expect("the command runs")
11230                    })
11231                })
11232                .collect();
11233            handles
11234                .into_iter()
11235                .map(|handle| handle.join().unwrap())
11236                .collect()
11237        });
11238
11239        for (index, result) in results.iter().enumerate() {
11240            println!(
11241                "command {index}: exit {} stdout {:?}",
11242                result.exit_code,
11243                result.stdout.trim()
11244            );
11245            assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
11246        }
11247        for client in &results[1..] {
11248            assert_eq!(client.stdout.trim(), "pong");
11249        }
11250        println!(
11251            "namespaces created: {}",
11252            enforcement.session_netns.creations()
11253        );
11254        assert_eq!(enforcement.session_netns.creations(), 1);
11255
11256        enforcement.end_session();
11257    }
11258
11259    /// Ending the session kills everything it started — a `setsid` process and a background
11260    /// server included — releases the namespace, and refuses every command after.
11261    #[test]
11262    #[allow(clippy::print_stdout)]
11263    fn ending_the_session_kills_everything_it_started() {
11264        if crate::cgroup::skip_without_host_support(
11265            "ending_the_session_kills_everything_it_started",
11266        ) {
11267            return;
11268        }
11269        let temp = tempfile::tempdir().unwrap();
11270        let policy = server_policy(&["setsid"]);
11271        let scope =
11272            crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
11273                .expect("a delegated scope")
11274                .scope
11275                .expect("a required scope on Linux is never None");
11276        let enforcement =
11277            session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
11278
11279        let started = bash(
11280            "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
11281             nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
11282            temp.path(),
11283            &policy,
11284            &enforcement,
11285        )
11286        .expect("the command runs");
11287        assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
11288        let sleeper = pid_in(temp.path(), "sleeper.pid");
11289        let server = pid_in(temp.path(), "server.pid");
11290        assert!(process_exists(sleeper), "the setsid sleeper is running");
11291        assert!(process_exists(server), "the background server is running");
11292
11293        let ending = std::time::Instant::now();
11294        enforcement.end_session();
11295        let took = ending.elapsed();
11296
11297        let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
11298        let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
11299        let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
11300        let after = bash("true", temp.path(), &policy, &enforcement);
11301        println!(
11302            "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
11303             gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
11304            took.as_millis(),
11305            procs.trim(),
11306            after.as_ref().map(|result| result.exit_code)
11307        );
11308        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
11309        assert!(sleeper_gone, "the setsid sleeper outlived its session");
11310        assert!(server_gone, "the background server outlived its session");
11311        assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
11312        let refusal = after
11313            .expect_err("a command after the session ended must be refused")
11314            .to_string();
11315        assert!(refusal.contains("session has ended"), "{refusal}");
11316    }
11317
11318    // ---- the session's temporary folder ----
11319    //
11320    // Each test below resolves its enforcement the way a session does, over a live `HostProbe`,
11321    // and maps `/tmp` with the answer staging would give. One that needs a private mount
11322    // namespace prints a `[SKIP-HOST]` line and returns on a host that cannot give one.
11323
11324    /// A directory under `HOME`, outside `/tmp`, removed when the value drops.
11325    fn home_tempdir() -> tempfile::TempDir {
11326        tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
11327    }
11328
11329    /// A session's enforcement at `class` from a live probe, holding a runtime directory, records
11330    /// directory and temporary folder under `base`, with the `/tmp` mapping staging decides for
11331    /// `workdir`; `None` after a skip line naming `test` when that mapping is not
11332    /// [`crate::temp_dir::TempMapping::MountNamespace`].
11333    fn mapped_enforcement(
11334        test: &str,
11335        policy: &CapabilityPolicy,
11336        class: murmur_artifact::ContainmentClass,
11337        base: &Path,
11338        workdir: &Path,
11339    ) -> Option<ShellEnforcement> {
11340        if crate::network_namespace::skip_without_egress_namespace(test) {
11341            return None;
11342        }
11343        let probe = HostProbe::probe();
11344        let mount_namespace_usable = probe.mount_namespace_usable();
11345        let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
11346            .unwrap()
11347            .with_runtime_dir(test_runtime_dir(base), None);
11348        let mapping =
11349            crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
11350        if mapping != crate::temp_dir::TempMapping::MountNamespace {
11351            crate::runtime_err!(
11352                "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
11353                 {:?})",
11354                enforcement.tier
11355            );
11356            return None;
11357        }
11358        Some(enforcement.with_temp_mapping(mapping))
11359    }
11360
11361    /// The same session enforcement with the mount namespace probed as unusable: `KernelFull`,
11362    /// `/tmp` not mapped. `None` after a skip line on a host that has no `KernelFull` at all.
11363    fn unmapped_kernel_full_enforcement(
11364        test: &str,
11365        policy: &CapabilityPolicy,
11366        base: &Path,
11367        workdir: &Path,
11368    ) -> Option<ShellEnforcement> {
11369        if crate::network_namespace::skip_without_egress_namespace(test) {
11370            return None;
11371        }
11372        let live = HostProbe::probe();
11373        if !matches!(
11374            live.tier(),
11375            EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11376        ) {
11377            crate::runtime_err!(
11378                "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
11379                live.tier()
11380            );
11381            return None;
11382        }
11383        let refused = crate::namespace_probe::MountAttempt::Failed(
11384            crate::namespace_probe::MountAttemptError::Step {
11385                step: crate::namespace_probe::MountStep::UnshareMount,
11386                errno: libc::EPERM,
11387            },
11388        );
11389        let probe = HostProbe::from_parts(
11390            Some(true),
11391            crate::namespace_probe::NamespaceReading {
11392                mount: refused.clone(),
11393                sealed_root: refused,
11394                ..live.namespaces().clone()
11395            },
11396        )
11397        .with_landlock_kernel_abi(live.landlock_kernel_abi());
11398        assert!(!probe.mount_namespace_usable());
11399        let enforcement = ShellEnforcement::resolve_declared(
11400            policy,
11401            murmur_artifact::ContainmentClass::Scoped,
11402            probe,
11403        )
11404        .unwrap()
11405        .with_runtime_dir(test_runtime_dir(base), None);
11406        assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
11407        let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
11408        assert_eq!(
11409            mapping,
11410            crate::temp_dir::TempMapping::NotMapped(
11411                crate::temp_dir::NotMappedReason::NoMountNamespace
11412            )
11413        );
11414        Some(enforcement.with_temp_mapping(mapping))
11415    }
11416
11417    /// A name no other test or run uses beneath `/tmp`.
11418    fn unique_tmp_name(label: &str) -> String {
11419        static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
11420        format!(
11421            "murmur-probe-{label}-{}-{}",
11422            std::process::id(),
11423            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
11424        )
11425    }
11426
11427    /// The parent-side spec re-exposes the work folder and the home folder read-write and the
11428    /// runtime and records directories and every grant path read-only, each at its exact path
11429    /// beneath `/tmp`, the home folder over the records directory it lies in.
11430    #[test]
11431    fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
11432        let base = tempfile::tempdir().unwrap();
11433        let workdir = tempfile::tempdir().unwrap();
11434        let grant_dir = tempfile::tempdir().unwrap();
11435        let grant = grant_dir.path().join("bin");
11436        std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
11437        let mut enforcement = ShellEnforcement {
11438            tier: EnforcementTier::KernelFull,
11439            ..ShellEnforcement::environment_only().with_test_session_dirs()
11440        }
11441        .with_runtime_dir(test_runtime_dir(base.path()), None)
11442        .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
11443        enforcement.landlock_grants =
11444            LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
11445        let folder = enforcement
11446            .runtime_dir
11447            .as_ref()
11448            .and_then(|roots| roots.temp.as_ref())
11449            .unwrap()
11450            .path_fd()
11451            .unwrap();
11452
11453        let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
11454
11455        let canonical = |path: &Path| {
11456            std::fs::canonicalize(path)
11457                .unwrap()
11458                .to_string_lossy()
11459                .into_owned()
11460        };
11461        let mut expected = vec![
11462            (canonical(workdir.path()), false),
11463            (
11464                canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
11465                true,
11466            ),
11467            (
11468                canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
11469                true,
11470            ),
11471            (
11472                canonical(
11473                    &base
11474                        .path()
11475                        .join(crate::records_dir::RECORDS_DIR_NAME)
11476                        .join(crate::records_dir::HOME_SUBDIR),
11477                ),
11478                false,
11479            ),
11480            (canonical(&grant), true),
11481        ];
11482        expected.sort();
11483        assert_eq!(spec.reexposed_sources(), expected);
11484    }
11485
11486    /// At `scoped` and at `advisory`, a literal `/tmp` path lands in the session's temporary
11487    /// folder, `TMPDIR` is `/tmp`, and the host's `/tmp` gains nothing.
11488    #[test]
11489    fn a_literal_tmp_path_reaches_the_session_temp_folder() {
11490        const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
11491        for class in [
11492            murmur_artifact::ContainmentClass::Scoped,
11493            murmur_artifact::ContainmentClass::Advisory,
11494        ] {
11495            let base = home_tempdir();
11496            let workdir = home_tempdir();
11497            let policy = coreutils_policy(&[]);
11498            let Some(enforcement) =
11499                mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
11500            else {
11501                return;
11502            };
11503            let name = unique_tmp_name("literal");
11504            let result = run_bash_in(
11505                workdir.path(),
11506                &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
11507                &policy,
11508                &enforcement,
11509            );
11510            assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
11511            assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
11512            let folder = enforcement.temp_dir_path().unwrap();
11513            assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
11514            assert!(
11515                !Path::new("/tmp").join(&name).exists(),
11516                "{class:?}: the host's /tmp gained {name}"
11517            );
11518        }
11519    }
11520
11521    /// `KernelSeccompOnly` maps `/tmp` through the same per-command mount namespace: the session
11522    /// enforcement with its tier set to seccomp-only, so no Landlock domain is installed.
11523    #[test]
11524    fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
11525        const TEST: &str =
11526            "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
11527        let base = home_tempdir();
11528        let workdir = home_tempdir();
11529        let policy = coreutils_policy(&[]);
11530        let Some(enforcement) = mapped_enforcement(
11531            TEST,
11532            &policy,
11533            murmur_artifact::ContainmentClass::Scoped,
11534            base.path(),
11535            workdir.path(),
11536        ) else {
11537            return;
11538        };
11539        let enforcement = ShellEnforcement {
11540            tier: EnforcementTier::KernelSeccompOnly,
11541            ..enforcement
11542        };
11543        assert_eq!(
11544            enforcement.installed_temp_mapping(),
11545            crate::temp_dir::TempMapping::MountNamespace
11546        );
11547        let name = unique_tmp_name("seccomp-only");
11548        let result = run_bash_in(
11549            workdir.path(),
11550            &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
11551            &policy,
11552            &enforcement,
11553        );
11554        assert_eq!(result.exit_code, 0, "{}", result.stderr);
11555        assert_eq!(result.stdout, "x\n/tmp\n");
11556        let folder = enforcement.temp_dir_path().unwrap();
11557        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
11558        assert!(
11559            !Path::new("/tmp").join(&name).exists(),
11560            "the host's /tmp gained {name}"
11561        );
11562    }
11563
11564    /// Two sessions' commands each see their own folder at `/tmp`.
11565    #[test]
11566    fn two_sessions_do_not_share_tmp() {
11567        const TEST: &str = "two_sessions_do_not_share_tmp";
11568        let policy = coreutils_policy(&[]);
11569        let (base_a, base_b) = (home_tempdir(), home_tempdir());
11570        let (work_a, work_b) = (home_tempdir(), home_tempdir());
11571        let class = murmur_artifact::ContainmentClass::Scoped;
11572        let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
11573            return;
11574        };
11575        let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
11576            return;
11577        };
11578        let name = unique_tmp_name("shared");
11579
11580        let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
11581        assert_eq!(first.exit_code, 0, "{}", first.stderr);
11582        let second = run_bash_in(
11583            work_b.path(),
11584            &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
11585            &policy,
11586            &b,
11587        );
11588        assert_eq!(second.exit_code, 0, "{}", second.stderr);
11589        assert_eq!(second.stdout, "b\n");
11590        let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
11591        assert_eq!(again.stdout, "a\n", "{}", again.stderr);
11592        assert_ne!(a.temp_dir_path(), b.temp_dir_path());
11593        assert!(!Path::new("/tmp").join(&name).exists());
11594    }
11595
11596    /// With the workdir, the runtime and records directories and `HOME` all beneath the host's
11597    /// `/tmp`, every one of them is still reached at its own path while `/tmp` is the folder.
11598    #[test]
11599    fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
11600        const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
11601        let base = tempfile::tempdir().unwrap();
11602        let workdir = tempfile::tempdir().unwrap();
11603        assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
11604        let policy = coreutils_policy(&[]);
11605        let Some(enforcement) = mapped_enforcement(
11606            TEST,
11607            &policy,
11608            murmur_artifact::ContainmentClass::Scoped,
11609            base.path(),
11610            workdir.path(),
11611        ) else {
11612            return;
11613        };
11614        std::fs::write(
11615            enforcement.runtime_dir_path().unwrap().join("probe"),
11616            b"runtime\n",
11617        )
11618        .unwrap();
11619        let name = unique_tmp_name("under");
11620        let result = run_bash_in(
11621            workdir.path(),
11622            &format!(
11623                "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
11624                 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
11625                 cat /tmp/{name}"
11626            ),
11627            &policy,
11628            &enforcement,
11629        );
11630        assert_eq!(result.exit_code, 0, "{}", result.stderr);
11631        assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
11632        assert_eq!(
11633            std::fs::read_to_string(workdir.path().join("f")).unwrap(),
11634            "a\n"
11635        );
11636
11637        // Only the paths the command needs are re-exposed, and only the workdir writably: a
11638        // sibling of the records directory is absent, and the records directory refuses a write
11639        // although it now lies beneath the folder's writable rule.
11640        let sibling = base.path().join("sibling");
11641        std::fs::write(&sibling, b"host\n").unwrap();
11642        let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
11643        std::fs::write(&record, b"{}\n").unwrap();
11644        let refused = run_bash_in(
11645            workdir.path(),
11646            &format!(
11647                "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
11648                sibling = sibling.display()
11649            ),
11650            &policy,
11651            &enforcement,
11652        );
11653        assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
11654        assert_ne!(refused.exit_code, 0);
11655        assert!(
11656            refused.stderr.contains("Read-only file system"),
11657            "{}",
11658            refused.stderr
11659        );
11660        assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
11661        let folder = enforcement.temp_dir_path().unwrap();
11662        assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
11663        assert!(!Path::new("/tmp").join(&name).exists());
11664    }
11665
11666    /// Without a usable mount namespace, `KernelFull` maps nothing: a literal `/tmp` write is
11667    /// refused by Landlock, and `$TMPDIR` is the folder's absolute path and works.
11668    #[test]
11669    fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
11670        const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
11671        let base = home_tempdir();
11672        let workdir = home_tempdir();
11673        let policy = coreutils_policy(&[]);
11674        let Some(enforcement) =
11675            unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
11676        else {
11677            return;
11678        };
11679        let name = unique_tmp_name("refused");
11680        let refused = run_bash_in(
11681            workdir.path(),
11682            &format!("echo y > /tmp/{name}"),
11683            &policy,
11684            &enforcement,
11685        );
11686        assert_ne!(refused.exit_code, 0);
11687        assert!(
11688            refused.stderr.contains("Permission denied"),
11689            "{}",
11690            refused.stderr
11691        );
11692        assert!(!Path::new("/tmp").join(&name).exists());
11693
11694        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
11695        let works = run_bash_in(
11696            workdir.path(),
11697            "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
11698             \"$TEMP\"",
11699            &policy,
11700            &enforcement,
11701        );
11702        assert_eq!(works.exit_code, 0, "{}", works.stderr);
11703        let folder_text = folder.to_string_lossy();
11704        assert_eq!(
11705            works.stdout,
11706            format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
11707        );
11708        assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
11709    }
11710
11711    /// The records directory stays read-only apart from `tmp/`: no record can be linked, moved
11712    /// or appended into the folder, and the folder itself cannot be renamed — mapped and not.
11713    #[test]
11714    fn a_record_cannot_be_moved_or_linked_into_tmp() {
11715        const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
11716        let policy = coreutils_policy(&[]);
11717        for mapped in [true, false] {
11718            let base = home_tempdir();
11719            let workdir = home_tempdir();
11720            let enforcement = if mapped {
11721                mapped_enforcement(
11722                    TEST,
11723                    &policy,
11724                    murmur_artifact::ContainmentClass::Scoped,
11725                    base.path(),
11726                    workdir.path(),
11727                )
11728            } else {
11729                unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
11730            };
11731            let Some(enforcement) = enforcement else {
11732                return;
11733            };
11734            let records = enforcement.records_dir_path().unwrap().to_path_buf();
11735            let record = records.join("trace.jsonl");
11736            std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
11737            let before = std::fs::read(&record).unwrap();
11738            for script in [
11739                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
11740                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
11741                "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
11742                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
11743                "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
11744                "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
11745                "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
11746            ] {
11747                let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
11748                assert_ne!(
11749                    result.exit_code, 0,
11750                    "mapped: {mapped}: `{script}` succeeded"
11751                );
11752            }
11753            assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
11754            assert!(records
11755                .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
11756                .is_dir());
11757            assert!(!records.join("tmp-moved").exists());
11758        }
11759    }
11760
11761    /// The folder carries the workdir's right-set, `Execute` axis included: a binary copied into
11762    /// `/tmp` runs only with `workdir_exec: true`.
11763    #[test]
11764    fn tmp_follows_workdir_exec() {
11765        const TEST: &str = "tmp_follows_workdir_exec";
11766        for (workdir_exec, expected) in [(false, 126), (true, 0)] {
11767            let base = home_tempdir();
11768            let workdir = home_tempdir();
11769            let policy = CapabilityPolicy {
11770                workdir_exec_allowed: workdir_exec,
11771                ..coreutils_policy(&["cp", "true"])
11772            };
11773            let Some(enforcement) = mapped_enforcement(
11774                TEST,
11775                &policy,
11776                murmur_artifact::ContainmentClass::Advisory,
11777                base.path(),
11778                workdir.path(),
11779            ) else {
11780                return;
11781            };
11782            let result = run_bash_in(
11783                workdir.path(),
11784                "cp /bin/true /tmp/t && /tmp/t",
11785                &policy,
11786                &enforcement,
11787            );
11788            assert_eq!(
11789                result.exit_code, expected,
11790                "workdir_exec: {workdir_exec}: {}",
11791                result.stderr
11792            );
11793            if !workdir_exec {
11794                assert!(
11795                    result.stderr.contains("Permission denied"),
11796                    "{}",
11797                    result.stderr
11798                );
11799            }
11800        }
11801    }
11802
11803    /// A mapping that cannot be built fails the spawn with its own error and runs nothing.
11804    #[test]
11805    fn a_failed_tmp_mapping_spawns_nothing() {
11806        const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
11807        let base = home_tempdir();
11808        let workdir = home_tempdir();
11809        let policy = coreutils_policy(&[]);
11810        let Some(enforcement) = mapped_enforcement(
11811            TEST,
11812            &policy,
11813            murmur_artifact::ContainmentClass::Scoped,
11814            base.path(),
11815            workdir.path(),
11816        ) else {
11817            return;
11818        };
11819        let error = {
11820            let _forced = ForceTmpMapFailureGuard::new();
11821            crate::shell::execute_shell(
11822                "bash",
11823                &["-c", "echo ran > \"$(pwd)/marker\""],
11824                &[],
11825                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11826                &policy,
11827                &enforcement,
11828            )
11829            .expect_err("a failed mapping fails the spawn")
11830        };
11831        let message = error.to_string();
11832        println!("{message}");
11833        assert!(
11834            message.contains("could not map /tmp onto this session's temporary folder"),
11835            "{message}"
11836        );
11837        assert!(
11838            matches!(error, crate::shell::ShellExecError::Failed(_)),
11839            "an ordinary spawn failure, not a session-fatal one: {error:?}"
11840        );
11841        assert!(!workdir.path().join("marker").exists(), "the command ran");
11842    }
11843
11844    /// A link swapped onto the folder's path after the session holds it redirects no bind: the
11845    /// child's open reaches another directory, and the spawn fails naming the step.
11846    #[test]
11847    fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
11848        const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
11849        let base = home_tempdir();
11850        let workdir = home_tempdir();
11851        let policy = coreutils_policy(&[]);
11852        let Some(enforcement) = mapped_enforcement(
11853            TEST,
11854            &policy,
11855            murmur_artifact::ContainmentClass::Scoped,
11856            base.path(),
11857            workdir.path(),
11858        ) else {
11859            return;
11860        };
11861        let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
11862        let decoy = base.path().join("decoy");
11863        std::fs::create_dir(&decoy).unwrap();
11864        std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
11865        std::os::unix::fs::symlink(&decoy, &folder).unwrap();
11866
11867        let error = crate::shell::execute_shell(
11868            "bash",
11869            &["-c", "echo x > /tmp/swapped"],
11870            &[],
11871            &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11872            &policy,
11873            &enforcement,
11874        )
11875        .expect_err("a swapped folder fails the spawn")
11876        .to_string();
11877        println!("{error}");
11878        assert!(
11879            error.contains("could not map /tmp onto this session's temporary folder"),
11880            "{error}"
11881        );
11882        assert!(error.contains("opening the temporary folder"), "{error}");
11883        assert!(
11884            error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
11885            "{error}"
11886        );
11887        assert_eq!(
11888            std::fs::read_dir(&decoy).unwrap().count(),
11889            0,
11890            "the link's target was written"
11891        );
11892    }
11893
11894    /// A policy allowlisting `binary` with its interpreter runtime derived the way
11895    /// `stage_session` derives it, `work` excluded as the capsule-writable root, through a host
11896    /// facts cache of its own.
11897    fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
11898        let mut policy = CapabilityPolicy {
11899            shell_allow: vec![binary.to_string()],
11900            ..CapabilityPolicy::default()
11901        };
11902        let cache = tempfile::tempdir().unwrap();
11903        policy.shell_derived_interpreter_runtime =
11904            crate::interpreter_runtime::derive_interpreter_runtimes(
11905                &policy.shell_allow,
11906                &[],
11907                &[work],
11908                &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
11909            )
11910            .runtimes;
11911        policy
11912    }
11913
11914    /// What `program` prints when this host's `python3` runs it outside any sandbox.
11915    fn unsandboxed_python(program: &str) -> String {
11916        let output = std::process::Command::new("python3")
11917            .args(["-I", "-c", program])
11918            .output()
11919            .unwrap();
11920        assert!(output.status.success(), "{output:?}");
11921        String::from_utf8(output.stdout).unwrap().trim().to_string()
11922    }
11923
11924    /// Asserts the derived runtime holds the directories `encodings` and `_sqlite3` load from on
11925    /// this host, as the interpreter itself reports them, and returns the first: the stdlib.
11926    fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
11927        let stdlib = PathBuf::from(unsandboxed_python(
11928            "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
11929        ));
11930        let dynload = PathBuf::from(unsandboxed_python(
11931            "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
11932        ));
11933        let derived = &policy.shell_derived_interpreter_runtime;
11934        println!("derived: {derived:#?}");
11935        let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
11936        let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
11937        assert!(
11938            dirs.contains(&&canonical(&stdlib)),
11939            "{stdlib:?} not in {dirs:?}"
11940        );
11941        assert!(
11942            dirs.contains(&&canonical(&dynload)),
11943            "{dynload:?} not in {dirs:?}"
11944        );
11945        canonical(&stdlib)
11946    }
11947
11948    /// The program that opens `secret` and lists `parent`, each in its own `try`, printing the
11949    /// exception class name or `allowed`.
11950    fn python_probe_outside(secret: &Path, parent: &Path) -> String {
11951        format!(
11952            "import os\n\
11953             for label, act in (('open', lambda: open({secret:?}).read()), \
11954             ('listdir', lambda: os.listdir({parent:?}))):\n\
11955             \x20   try:\n\
11956             \x20       act(); print(label, 'allowed')\n\
11957             \x20   except Exception as error:\n\
11958             \x20       print(label, type(error).__name__)\n",
11959            secret = secret.to_string_lossy(),
11960            parent = parent.to_string_lossy(),
11961        )
11962    }
11963
11964    fn execute(
11965        binary: &str,
11966        args: &[&str],
11967        accessible: &Path,
11968        policy: &CapabilityPolicy,
11969        enforcement: &ShellEnforcement,
11970    ) -> crate::shell::ShellResult {
11971        let result = crate::shell::execute_shell(
11972            binary,
11973            args,
11974            &[],
11975            &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
11976            policy,
11977            enforcement,
11978        )
11979        .unwrap();
11980        println!(
11981            "exit {}\n--- stdout\n{}--- stderr\n{}---",
11982            result.exit_code, result.stdout, result.stderr
11983        );
11984        result
11985    }
11986
11987    /// A policy allowlisting `shell_allow` and reaching `network_allow`, with the trust store
11988    /// derived the way `stage_session` derives it, `work` excluded as the capsule-writable root.
11989    fn trust_store_policy(
11990        shell_allow: &[&str],
11991        network_allow: &[&str],
11992        work: &Path,
11993    ) -> CapabilityPolicy {
11994        let mut policy = CapabilityPolicy {
11995            shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
11996            network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
11997            ..CapabilityPolicy::default()
11998        };
11999        policy.shell_trust_store = crate::trust_store::derive_trust_store(
12000            &policy.shell_allow,
12001            &policy.network_allow,
12002            &[work],
12003        );
12004        println!("derived trust store: {:#?}", policy.shell_trust_store);
12005        policy
12006    }
12007
12008    /// The first CA bundle file of the candidate table this host has.
12009    fn host_ca_bundle() -> Option<PathBuf> {
12010        crate::trust_store::TRUST_STORE_CANDIDATES
12011            .iter()
12012            .filter(|candidate| !candidate.directory)
12013            .map(|candidate| PathBuf::from(candidate.path))
12014            .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
12015    }
12016
12017    /// An allowlisted `curl` in a capsule with a network allowlist and no `interpreter_runtime`
12018    /// block verifies a server certificate under `scoped`.
12019    #[test]
12020    fn a_scoped_curl_with_network_allow_completes_an_https_request() {
12021        let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
12022        if find_on_path("curl").is_none() {
12023            eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
12024            return;
12025        }
12026        let unsandboxed = std::process::Command::new("curl")
12027            .args([
12028                "-sS",
12029                "-o",
12030                "/dev/null",
12031                "-w",
12032                "%{http_code}",
12033                "--max-time",
12034                "15",
12035            ])
12036            .arg("https://example.com/")
12037            .output()
12038            .unwrap();
12039        if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
12040            eprintln!(
12041                "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
12042                 the sandbox; it gave {unsandboxed:?}"
12043            );
12044            return;
12045        }
12046        let (_base, work, _outside) = work_and_outside();
12047        let policy = trust_store_policy(&["curl"], &["example.com"], &work);
12048        assert!(policy.shell_interpreter_runtime.is_empty());
12049        assert!(
12050            !policy.shell_trust_store.is_empty(),
12051            "this host has a trust store the candidate table names"
12052        );
12053        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12054            return;
12055        };
12056
12057        let fetched = execute(
12058            "curl",
12059            &[
12060                "-sS",
12061                "-o",
12062                "/dev/null",
12063                "-w",
12064                "%{http_code}",
12065                "--max-time",
12066                "30",
12067                "https://example.com/",
12068            ],
12069            &work,
12070            &policy,
12071            &enforcement,
12072        );
12073        enforcement.end_session();
12074        assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
12075        assert_eq!(fetched.stdout, "200");
12076    }
12077
12078    /// The trust store reaches a command only when the capsule declares a network allowlist.
12079    #[test]
12080    fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
12081        let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
12082        assert!(
12083            crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
12084        );
12085        let Some(bundle) = host_ca_bundle() else {
12086            eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
12087            return;
12088        };
12089        let bundle_arg = bundle.to_str().unwrap();
12090        let (_base, work, _outside) = work_and_outside();
12091
12092        let offline = trust_store_policy(&["cat"], &[], &work);
12093        assert!(offline.shell_trust_store.is_empty());
12094        let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
12095        assert!(!online.shell_trust_store.is_empty());
12096        let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
12097            return;
12098        };
12099        for derived in &online.shell_trust_store {
12100            assert!(
12101                !enforcement
12102                    .landlock_grants
12103                    .iter()
12104                    .any(|grant| grant.path == derived.path),
12105                "{derived:?} granted without network.allow"
12106            );
12107        }
12108        let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
12109        enforcement.end_session();
12110        assert_ne!(denied.exit_code, 0);
12111        assert!(
12112            denied.stderr.contains("Permission denied"),
12113            "{}",
12114            denied.stderr
12115        );
12116
12117        let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
12118            return;
12119        };
12120        let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
12121        enforcement.end_session();
12122        assert_eq!(read.exit_code, 0, "{}", read.stderr);
12123        assert!(
12124            read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
12125            "{}",
12126            &read.stdout[..read.stdout.len().min(200)]
12127        );
12128    }
12129
12130    /// The trust-store grant reaches the certificates and nothing beside them: not the directory
12131    /// above, its OpenSSL configuration or its private keys, and nothing is writable.
12132    #[test]
12133    fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
12134        let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
12135        let (_base, work, _outside) = work_and_outside();
12136        let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
12137        for entry in &policy.shell_trust_store {
12138            for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
12139                assert_ne!(entry.path, Path::new(broad));
12140            }
12141            assert!(
12142                !entry.path.components().any(|c| c.as_os_str() == "private"),
12143                "{entry:?}"
12144            );
12145        }
12146        assert!(resolve_trust_store_grants(&policy.shell_trust_store)
12147            .iter()
12148            .all(|grant| !grant.executable));
12149        if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
12150            eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
12151            return;
12152        }
12153        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12154            return;
12155        };
12156
12157        let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
12158        assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
12159        let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
12160        assert_ne!(above.exit_code, 0);
12161        assert!(
12162            above.stderr.contains("Permission denied"),
12163            "{}",
12164            above.stderr
12165        );
12166        if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
12167            let config = execute(
12168                "cat",
12169                &["/etc/ssl/openssl.cnf"],
12170                &work,
12171                &policy,
12172                &enforcement,
12173            );
12174            assert_ne!(config.exit_code, 0);
12175            assert!(
12176                config.stderr.contains("Permission denied"),
12177                "{}",
12178                config.stderr
12179            );
12180        }
12181        let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
12182        assert_ne!(private.exit_code, 0);
12183        let probe = Path::new("/etc/ssl/certs/murmur-probe");
12184        let written = execute(
12185            "bash",
12186            &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
12187            &work,
12188            &policy,
12189            &enforcement,
12190        );
12191        enforcement.end_session();
12192        assert_ne!(written.exit_code, 0);
12193        assert!(std::fs::symlink_metadata(probe).is_err());
12194    }
12195
12196    /// A `scoped` command cannot write into `~/.murmur/host_facts`: with the home outside the
12197    /// workdir, a shell redirection there is refused and leaves no file.
12198    #[test]
12199    fn a_scoped_command_cannot_write_the_host_facts_cache() {
12200        let test = "a_scoped_command_cannot_write_the_host_facts_cache";
12201        let home = home_tempdir();
12202        let workdir = home_tempdir();
12203        let cache = home.path().join(".murmur").join(
12204            crate::murmur_home::HomeEntryKind::HostFacts
12205                .file_name()
12206                .unwrap(),
12207        );
12208        crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
12209        crate::murmur_home::hold_private_dir(&cache).unwrap();
12210        let policy = coreutils_policy(&[]);
12211        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12212            return;
12213        };
12214        let probe = cache.join("probe");
12215        let written = run_bash_in(
12216            workdir.path(),
12217            &format!("printf x > '{}'", probe.display()),
12218            &policy,
12219            &enforcement,
12220        );
12221        enforcement.end_session();
12222        assert_ne!(written.exit_code, 0);
12223        assert!(
12224            written.stderr.contains("Permission denied"),
12225            "{}",
12226            written.stderr
12227        );
12228        assert!(std::fs::symlink_metadata(&probe).is_err());
12229    }
12230
12231    #[test]
12232    fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
12233        let paths = [
12234            crate::trust_store::TrustStorePath {
12235                path: PathBuf::from("/etc/ssl/certs"),
12236                directory: true,
12237            },
12238            crate::trust_store::TrustStorePath {
12239                path: PathBuf::from("/etc/pki/tls/cacert.pem"),
12240                directory: false,
12241            },
12242        ];
12243        assert_eq!(
12244            resolve_trust_store_grants(&paths),
12245            vec![
12246                LandlockGrant {
12247                    path: PathBuf::from("/etc/ssl/certs"),
12248                    list_dir: true,
12249                    executable: false,
12250                    pinned: None,
12251                },
12252                LandlockGrant {
12253                    path: PathBuf::from("/etc/pki/tls/cacert.pem"),
12254                    list_dir: false,
12255                    executable: false,
12256                    pinned: None,
12257                },
12258            ]
12259        );
12260        assert!(resolve_trust_store_grants(&[]).is_empty());
12261    }
12262
12263    /// `ShellEnforcement::resolve` adds exactly the policy's trust-store grants on both Landlock
12264    /// tiers, whatever the host, and leaves the sealed `/etc` grants in place.
12265    #[test]
12266    fn shell_enforcement_resolve_adds_the_trust_store() {
12267        use murmur_artifact::ContainmentClass;
12268        let store = vec![
12269            crate::trust_store::TrustStorePath {
12270                path: PathBuf::from("/murmur-test/certs"),
12271                directory: true,
12272            },
12273            crate::trust_store::TrustStorePath {
12274                path: PathBuf::from("/murmur-test/bundle.pem"),
12275                directory: false,
12276            },
12277        ];
12278        let expected = resolve_trust_store_grants(&store);
12279        let with_store = CapabilityPolicy {
12280            shell_trust_store: store.clone(),
12281            ..CapabilityPolicy::default()
12282        };
12283        let without = CapabilityPolicy::default();
12284        for (tier, declared) in [
12285            (EnforcementTier::KernelFull, ContainmentClass::Scoped),
12286            (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
12287        ] {
12288            let decision =
12289                crate::containment::ContainmentDecision::decide(tier, declared, false, false);
12290            let granted = |policy: &CapabilityPolicy| {
12291                let enforcement =
12292                    ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
12293                assert_eq!(enforcement.tier, tier);
12294                enforcement.landlock_grants
12295            };
12296            let with_grants = granted(&with_store);
12297            let without_grants = granted(&without);
12298            for grant in &expected {
12299                assert_eq!(
12300                    with_grants.iter().filter(|g| *g == grant).count(),
12301                    1,
12302                    "{tier:?}: {grant:?}"
12303                );
12304            }
12305            assert!(without_grants
12306                .iter()
12307                .all(|grant| !grant.path.starts_with("/murmur-test")));
12308            assert_eq!(with_grants.len(), without_grants.len() + expected.len());
12309            let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
12310            assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
12311            for grant in &sealed_etc {
12312                assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
12313            }
12314        }
12315    }
12316
12317    /// An allowlisted `python3` with no `interpreter_runtime` block imports its standard library
12318    /// and a C extension needing a library outside its own closure under `scoped`, while a file
12319    /// outside its runtime and the directory above its stdlib stay denied.
12320    #[test]
12321    fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
12322        let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
12323        if find_on_path("python3").is_none() {
12324            println!("[SKIP] {test}: no python3 on PATH");
12325            return;
12326        }
12327        let (_base, work, outside) = work_and_outside();
12328        let policy = derived_interpreter_policy("python3", &work);
12329        let stdlib = assert_derives_stdlib_and_sqlite(&policy);
12330        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12331            return;
12332        };
12333
12334        let imported = execute(
12335            "python3",
12336            &["-c", "import json, sqlite3, venv; print('imported')"],
12337            &work,
12338            &policy,
12339            &enforcement,
12340        );
12341        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
12342        assert_eq!(imported.stdout.trim(), "imported");
12343
12344        let secret = outside.join("secret.txt");
12345        std::fs::write(&secret, "secret").unwrap();
12346        let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
12347        let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
12348        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
12349        assert_eq!(
12350            denied.stdout.lines().collect::<Vec<_>>(),
12351            vec!["open PermissionError", "listdir PermissionError"]
12352        );
12353    }
12354
12355    /// The same interpreter under `sealed`: it imports what it imported under `scoped`, and the
12356    /// file outside its runtime is neither readable nor present in the composed root.
12357    #[test]
12358    fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
12359        let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
12360        if crate::network_namespace::skip_without_egress_namespace(test) {
12361            return;
12362        }
12363        if find_on_path("python3").is_none() {
12364            println!("[SKIP] {test}: no python3 on PATH");
12365            return;
12366        }
12367        let probe = HostProbe::probe();
12368        if probe.tier() != EnforcementTier::KernelSealed {
12369            eprintln!(
12370                "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
12371                 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
12372                probe.tier()
12373            );
12374            return;
12375        }
12376        let (_base, work, outside) = work_and_outside();
12377        let policy = derived_interpreter_policy("python3", &work);
12378        assert_derives_stdlib_and_sqlite(&policy);
12379        let runtime = tempfile::tempdir().unwrap();
12380        let enforcement = ShellEnforcement::resolve_declared(
12381            &policy,
12382            murmur_artifact::ContainmentClass::Sealed,
12383            probe,
12384        )
12385        .expect("a sealed-capable host resolves sealed enforcement")
12386        .with_runtime_dir(test_runtime_dir(runtime.path()), None);
12387        assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
12388
12389        let imported = execute(
12390            "python3",
12391            &["-c", "import json, sqlite3, venv; print('imported')"],
12392            &work,
12393            &policy,
12394            &enforcement,
12395        );
12396        assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
12397        assert_eq!(imported.stdout.trim(), "imported");
12398
12399        let secret = outside.join("secret.txt");
12400        std::fs::write(&secret, "secret").unwrap();
12401        let program = format!(
12402            "try:\n    open({:?}).read(); print('allowed')\nexcept Exception as error:\n    \
12403             print(type(error).__name__)\n",
12404            secret.to_string_lossy()
12405        );
12406        let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
12407        assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
12408        let outcome = denied.stdout.trim();
12409        assert!(
12410            outcome == "PermissionError" || outcome == "FileNotFoundError",
12411            "{outcome}"
12412        );
12413    }
12414
12415    /// An allowlisted `perl` with no `interpreter_runtime` block loads `POSIX`, an XS module, out
12416    /// of its own `@INC` under `scoped`.
12417    #[test]
12418    fn a_scoped_perl_loads_posix_from_its_inc() {
12419        let test = "a_scoped_perl_loads_posix_from_its_inc";
12420        if find_on_path("perl").is_none() {
12421            println!("[SKIP] {test}: no perl on PATH");
12422            return;
12423        }
12424        let (_base, work, _outside) = work_and_outside();
12425        let policy = derived_interpreter_policy("perl", &work);
12426        assert!(
12427            !policy.shell_derived_interpreter_runtime.is_empty(),
12428            "perl was not recognised or reported nothing"
12429        );
12430        let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12431            return;
12432        };
12433        let result = execute(
12434            "perl",
12435            &["-MPOSIX", "-e", "print qq{loaded\n}"],
12436            &work,
12437            &policy,
12438            &enforcement,
12439        );
12440        assert_eq!(result.exit_code, 0, "{}", result.stderr);
12441        assert_eq!(result.stdout.trim(), "loaded");
12442    }
12443}