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(®ion[..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, ©).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(®istry),
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}