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 (the only writable path outside the
45//! workdir), `/dev/zero` and `/dev/urandom` read-only, and no other device at all.
46//!
47//! Both Linux tiers additionally strip the forked child's **Linux capabilities** (bounding set,
48//! then permitted/effective/inheritable, then `no_new_privs`) before `execve()` — see
49//! `linux_enforce::drop_all_capabilities`. Landlock and the capability model are independent,
50//! both-must-allow gates: a root-uid capsule keeps `CAP_MKNOD` no matter what Landlock permits,
51//! so the Landlock narrowing above does not subsume this.
52//!
53//! Both are Linux-only. macOS (and any other non-Linux target) has no equivalent kernel
54//! primitive and permanently falls back to the existing, unmodified synthetic-HOME/env-
55//! stripping mechanism in `shell.rs` — see `EnforcementTier::EnvironmentOnly`.
56//!
57//! ## Four-tier model
58//!
59//! - `KernelSealed` (Linux, everything `KernelFull` has, plus a usable unprivileged user+mount
60//! namespace — AppArmor out of the way, `unshare` + `mount` verified by a real probe): the
61//! subprocess tree additionally runs inside a private mount namespace pivoted onto a composed
62//! root, so paths outside it are *absent* rather than denied. Landlock and seccomp still install,
63//! inside the new root, as defence in depth — see [`crate::sealed`]. Only capsules that declare
64//! `capabilities.containment: sealed` get this; see `applied_tier`.
65//! - `KernelFull` (Linux, Landlock ABI available — kernel 5.13+): seccomp syscall allowlist +
66//! socket-domain denial + Landlock filesystem scoping, which is also what makes
67//! `capabilities.shell.allow` enforceable (exec is a Landlock right, not a syscall filter).
68//! - `KernelSeccompOnly` (Linux, Landlock unavailable — kernel <5.13): seccomp syscall allowlist +
69//! socket-domain denial only. Filesystem scope stays convention-only (`current_dir`) — a
70//! documented gap, not a bug — and, since the exec supervisor was retired, so does
71//! `shell.allow`: with no Landlock domain there is no kernel-level exec mediation on this tier
72//! at all. `W-SEC-002` says so. The syscall allowlist and the socket-domain denial are identical
73//! on both tiers: they are seccomp rules, so they do not depend on Landlock in any way.
74//! - `EnvironmentOnly` (macOS / any non-Linux target): no kernel primitive attempted at all.
75//! Permanent, not a placeholder for a future slice.
76//!
77//! Tier detection is always a runtime capability probe (attempt Landlock ruleset
78//! construction, inspect the resulting `RulesetStatus`; fork a child and really call
79//! `unshare(CLONE_NEWUSER | CLONE_NEWNS)` followed by a `mount(2)`) — never a hardcoded
80//! kernel-version string parse, which is fragile against distro backports, and never a config
81//! file. Each reading is taken live, and a staged session takes exactly one for every consumer
82//! of that session to read; see [`HostProbe`].
83//!
84//! ## Fail-closed invariant
85//!
86//! If kernel enforcement setup fails unexpectedly on a Linux host (not the expected
87//! "Landlock unsupported, degrade to `KernelSeccompOnly`" signal, but something like seccomp
88//! filter install failing, or the session's network namespace failing to be created before
89//! spawn) — `prepare_enforcement` returns `Err` and `execute_shell` must not call `.spawn()` at
90//! all.
91//! There is no code path where a Linux host silently runs a shell subprocess with zero
92//! enforcement because setup failed.
93
94use std::net::IpAddr;
95use std::path::{Path, PathBuf};
96use std::sync::Arc;
97
98use murmur_artifact::security_warnings::{
99 security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
100};
101use murmur_artifact::InterpreterRuntimeGrant;
102
103use crate::types::CapabilityPolicy;
104
105/// Kernel-enforcement tier for the current host, in descending order of enforcement
106/// strength. Always host-probed at launch time — never sourced from the manifest.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub(crate) enum EnforcementTier {
109 /// Linux, and everything `KernelFull` has *plus* a private mount namespace pivoted onto a
110 /// composed root (see [`crate::sealed`]): the shipped AppArmor profile is loaded (or AppArmor
111 /// imposes no unprivileged-userns restriction), a real `unshare` + `mount` probe succeeded,
112 /// and the Landlock ABI is usable. Paths outside the composed root are *absent*, not merely
113 /// denied — and Landlock and seccomp still install inside it, as defence in depth.
114 ///
115 /// Ranked first because it is the strongest tier; the enum is documented weakest-last and
116 /// carries no `Ord`, so this position is documentation, not a comparison.
117 KernelSealed,
118 /// Linux, Landlock ABI available (kernel 5.13+): seccomp exec/network allowlisting
119 /// + Landlock filesystem scoping.
120 KernelFull,
121 /// Linux, Landlock unavailable (kernel <5.13): seccomp exec/network allowlisting
122 /// only. Filesystem scope stays convention-only (`current_dir`) — documented gap.
123 KernelSeccompOnly,
124 /// macOS (or any non-Linux target): no kernel sandboxing primitive exists. This is
125 /// PERMANENT, not a placeholder for a future slice.
126 EnvironmentOnly,
127}
128
129/// Pure decision: given whether the host is Linux, the outcome of a Landlock
130/// ruleset-restriction probe, and what [`crate::sealed::probe_sealed_support`] found, decide the
131/// tier. No syscalls here — fully unit-testable on any OS.
132///
133/// `KernelSealed` requires the whole conjunction — Linux, a usable Landlock ABI, AppArmor out of
134/// the way, and a namespace probe that really created one. Any missing element falls back to the
135/// tier that element still supports; nothing here ever reports a mechanism the host did not
136/// demonstrate.
137pub(crate) fn tier_from_probe(
138 is_linux: bool,
139 landlock_fully_enforced: Option<bool>,
140 sealed: crate::sealed::SealedProbe,
141) -> EnforcementTier {
142 if !is_linux {
143 return EnforcementTier::EnvironmentOnly;
144 }
145 match landlock_fully_enforced {
146 Some(true)
147 if sealed.userns_grant.permits_userns()
148 && sealed.namespace == crate::sealed::NamespaceProbe::Ok =>
149 {
150 EnforcementTier::KernelSealed
151 }
152 Some(true) => EnforcementTier::KernelFull,
153 Some(false) | None => EnforcementTier::KernelSeccompOnly,
154 }
155}
156
157/// The tier this *session* actually applies, which is never stronger than the host can back and
158/// never stronger than the capsule asked for.
159///
160/// A capsule declaring `scoped` on a sealed-capable host keeps running exactly as it does today:
161/// the composed root would otherwise silently delete host paths its `interpreter_runtime` grants
162/// legitimately point at — a narrowing of `scoped`'s behaviour, not added security. The
163/// declared floor is honoured, not merely met.
164///
165/// It is deliberately *not* symmetric with the achieved class recorded in the trace: `achieved`
166/// answers "what can this host back" (a host fact) and is probed independently, while this answers
167/// "what did this session install" (a session fact).
168pub(crate) fn applied_tier(
169 host_tier: EnforcementTier,
170 declared: murmur_artifact::ContainmentClass,
171) -> EnforcementTier {
172 match host_tier {
173 EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
174 EnforcementTier::KernelFull
175 }
176 other => other,
177 }
178}
179
180/// The host facts the tier decision is made from, taken together as one reading.
181///
182/// A staged session takes exactly one of these and every consumer of that session reads it — the
183/// containment-floor refusal, the `ScopeReport` written to the trace, and the tier
184/// [`ShellEnforcement::resolve`] installs. Two readings of a host that changed between them would
185/// let the trace describe a session that never ran under what it claims.
186#[derive(Debug, Clone, Copy)]
187pub struct HostProbe {
188 landlock_fully_enforced: Option<bool>,
189 /// The kernel's Landlock ABI, from `linux_enforce::kernel_landlock_abi`. `None` off Linux and
190 /// on a Linux kernel without Landlock.
191 landlock_kernel_abi: Option<u8>,
192 sealed: crate::sealed::SealedProbe,
193}
194
195/// Probes taken since this process started. See [`HostProbe::probes_taken`].
196static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
197
198impl HostProbe {
199 /// Reads the host as it is right now: on Linux a real Landlock ruleset restriction attempt
200 /// (`RulesetStatus::FullyEnforced` → `Some(true)`, `PartiallyEnforced`/`NotEnforced` or any
201 /// construction error → `Some(false)`, no Landlock ABI → `None`) plus a real
202 /// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` and `mount(2)`. Off Linux it probes nothing and
203 /// claims nothing.
204 ///
205 /// Taken once per staged session, not once per process. That is affordable because both
206 /// halves do their work in a forked child and leave the calling process exactly as they found
207 /// it — `linux_enforce::probe_landlock_full_access` forks so the runtime is never placed
208 /// into a Landlock domain by a capability check, and [`crate::sealed::probe_sealed_support`]
209 /// forks because `unshare(CLONE_NEWUSER)` is irreversible for the task that calls it. Neither
210 /// is idempotency-sensitive, so a reading costs no more than the syscalls it makes: two
211 /// `fork`/`waitpid` pairs and three small `/proc` reads.
212 ///
213 /// The window this leaves open is one session wide. Between `runtime::stage_session` and the
214 /// last subprocess that session spawns the host is not re-read; a change inside that window is
215 /// caught by the mechanism installation itself failing closed, not by a second probe. A
216 /// process that stages repeatedly — `mur eval`, which stages once per dataset case — judges
217 /// each session against the host as it was when that session was staged.
218 pub fn probe() -> HostProbe {
219 PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
220 #[cfg(target_os = "linux")]
221 let probe = HostProbe {
222 landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
223 landlock_kernel_abi: linux_enforce::kernel_landlock_abi(),
224 sealed: crate::sealed::probe_sealed_support(),
225 };
226 #[cfg(not(target_os = "linux"))]
227 let probe = HostProbe {
228 landlock_fully_enforced: None,
229 landlock_kernel_abi: None,
230 sealed: crate::sealed::SealedProbe::default(),
231 };
232 probe
233 }
234
235 /// A probe value assembled from known halves, without touching the host. The seam the
236 /// fail-closed tests use to assert what an all-negative reading resolves to on any OS.
237 #[cfg(test)]
238 pub(crate) fn from_parts(
239 landlock_fully_enforced: Option<bool>,
240 sealed: crate::sealed::SealedProbe,
241 ) -> HostProbe {
242 HostProbe {
243 landlock_fully_enforced,
244 landlock_kernel_abi: None,
245 sealed,
246 }
247 }
248
249 /// This probe with its kernel Landlock ABI reading replaced. The seam the ABI tests use to
250 /// resolve a session as a kernel reporting `abi` would, on a host that reports another.
251 #[cfg(test)]
252 pub(crate) fn with_landlock_kernel_abi(self, abi: Option<u8>) -> HostProbe {
253 HostProbe {
254 landlock_kernel_abi: abi,
255 ..self
256 }
257 }
258
259 /// The tier this reading supports. `pub(crate)` because [`EnforcementTier`] is.
260 pub(crate) fn tier(&self) -> EnforcementTier {
261 tier_from_probe(
262 cfg!(target_os = "linux"),
263 self.landlock_fully_enforced,
264 self.sealed,
265 )
266 }
267
268 /// Which single mechanism keeps this reading below `sealed`, or `None` when nothing does.
269 pub fn sealed_blocker(&self) -> Option<crate::sealed::SealedBlocker> {
270 crate::sealed::sealed_blocker(
271 cfg!(target_os = "linux"),
272 self.landlock_fully_enforced == Some(true),
273 self.sealed,
274 )
275 }
276
277 /// Where this host's permission to create an unprivileged user namespace comes from, or
278 /// `None` off Linux, where the question has no answer rather than a negative one.
279 pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
280 if cfg!(target_os = "linux") {
281 Some(self.sealed.userns_grant)
282 } else {
283 None
284 }
285 }
286
287 /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock (off Linux
288 /// always). The ABI the session's ruleset is built at is [`ruleset_landlock_abi`] of this.
289 pub fn landlock_kernel_abi(&self) -> Option<u8> {
290 self.landlock_kernel_abi
291 }
292
293 /// How many probes this process has taken, counting from zero at startup and never
294 /// decreasing.
295 ///
296 /// The observability hook the freshness tests assert against — that staging twice advances it
297 /// by two, and that one `stage_session` advances it by one — and nothing else reads it. No
298 /// runtime decision depends on the count.
299 pub fn probes_taken() -> u64 {
300 PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
301 }
302}
303
304/// The tier this host can back, from a reading taken here.
305///
306/// Answers a standalone host question — `mur doctor` and the escape-conformance harness ask it.
307/// A *session* must not: it reads its own [`HostProbe`] so its refusal, its trace and the
308/// mechanisms it installs describe one host at one moment.
309pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
310 HostProbe::probe().tier()
311}
312
313/// Which single mechanism keeps this host below `sealed`, or `None` when nothing does, from a
314/// reading taken here.
315///
316/// Takes its own probe, as its neighbours do. What a session needs — that the refusal an operator
317/// sees and the tier the runtime installs cannot disagree about the host — is upheld by
318/// `runtime::stage_session` passing one [`HostProbe`] to both, not by this function.
319pub fn detect_sealed_blocker() -> Option<crate::sealed::SealedBlocker> {
320 HostProbe::probe().sealed_blocker()
321}
322
323/// Where this host's unprivileged-user-namespace permission comes from, or `None` off Linux, where
324/// the question has no answer rather than a negative one.
325///
326/// Takes its own probe, as its neighbours do; a session reads the provenance off its own
327/// [`HostProbe`] instead, so what it is shown and the tier it installs describe one host at one
328/// moment.
329pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
330 HostProbe::probe().userns_grant()
331}
332
333/// The kernel's Landlock ABI version, or `None` when there is no Landlock (off Linux always), from
334/// a reading taken here.
335///
336/// Takes its own probe, as its neighbours do; a session reads it off its own [`HostProbe`].
337pub fn detect_landlock_abi() -> Option<u8> {
338 HostProbe::probe().landlock_kernel_abi()
339}
340
341/// The highest Landlock ABI a capsule's ruleset is built at.
342///
343/// v2 adds `Refer` (moving and hard-linking between folders) and v3 adds `Truncate`, both granted
344/// on the workdir — see `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC`. v4 is not taken: it
345/// handles TCP `bind`/`connect`, and declaring those rights would deny every TCP connection the
346/// rules do not re-grant, which is the network namespace and egress proxy's boundary to draw. v5
347/// (`IoctlDev`) and v6 (abstract unix sockets and signals) each move a different boundary again.
348pub(crate) const LANDLOCK_TARGET_ABI: u8 = 3;
349
350/// The Landlock ABI this session's ruleset is built at: the kernel's, capped at
351/// [`LANDLOCK_TARGET_ABI`], on the two tiers that install a Landlock domain, and `None` on every
352/// other tier or when the kernel reports no Landlock.
353///
354/// Pure, so the ABI the trace records and the ABI the child builds at are the same function of
355/// the same [`HostProbe`] reading.
356pub(crate) fn ruleset_landlock_abi(kernel_abi: Option<u8>, tier: EnforcementTier) -> Option<u8> {
357 match tier {
358 EnforcementTier::KernelFull | EnforcementTier::KernelSealed => kernel_abi
359 .filter(|abi| *abi >= 1)
360 .map(|abi| abi.min(LANDLOCK_TARGET_ABI)),
361 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => None,
362 }
363}
364
365/// The lines `mur doctor` prints under its `Landlock` heading for a kernel reporting
366/// `kernel_abi`: what the kernel offers, what the runtime builds at, and what a capsule loses on
367/// this host below [`LANDLOCK_TARGET_ABI`].
368///
369/// Pure and cross-platform, so a test can check the exact words for a kernel this host is not.
370pub fn landlock_doctor_lines(kernel_abi: Option<u8>) -> Vec<String> {
371 if !cfg!(target_os = "linux") {
372 return vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()];
373 }
374 let Some(kernel) = kernel_abi.filter(|abi| *abi >= 1) else {
375 return vec![
376 "kernel ABI: none (no Landlock on this host)".to_string(),
377 "ruleset ABI: n/a (no Landlock domain is installed)".to_string(),
378 ];
379 };
380 let built = kernel.min(LANDLOCK_TARGET_ABI);
381 let mut lines = vec![
382 format!("kernel ABI: {kernel}"),
383 format!("ruleset ABI: {built} (the kernel's, capped at {LANDLOCK_TARGET_ABI})"),
384 ];
385 if built < 2 {
386 lines.push(
387 "moving or hard-linking a file between folders inside the work folder fails with \
388 EXDEV (Invalid cross-device link) on this host; Linux 5.19 (Landlock ABI v2) \
389 permits it. Tools that fall back to copy-and-delete (mv, pip, git) still work; uv \
390 and other tools that rename atomically between folders do not."
391 .to_string(),
392 );
393 }
394 if built < 3 {
395 lines.push(
396 "truncate(2) on a file outside the work folder is not mediated below Landlock ABI v3 \
397 (Linux 6.2)."
398 .to_string(),
399 );
400 }
401 lines
402}
403
404/// Resolves every host in `network_allow` (via `crate::network_policy::parse_network_allow_rules`,
405/// the same parser already used for the WASI-HTTP-facing allowlist) to concrete IP addresses
406/// via DNS, once. Returns the deduplicated union of all resolved addresses across all allowed
407/// hosts.
408///
409/// Hosts that fail to resolve are silently skipped, matching `resolve_exec_allowlist`'s precedent
410/// below: the resolved set feeds *only* the kernel-level (Landlock/seccomp) allow set for
411/// `shell.allow` subprocesses, so a host contributing zero IPs merely *shrinks* that set (deny
412/// direction, fail-closed). A DNS miss must not be fatal — resolution happens unconditionally at
413/// staging time, before any WASM loads and regardless of whether the capsule declares
414/// `shell.allow` at all, so hard-failing here turned any transient resolver outage or
415/// non-resolving allowlist host into an E-IO-003 that blocked the whole run.
416///
417/// This does not widen what the capsule may reach: the WASM component's own outbound WASI-HTTP
418/// calls are gated separately by `network_policy::NetworkAllowRule::matches`, a host-pattern
419/// match that never consults DNS. Malformed host *syntax* is still a hard error — it is caught by
420/// `parse_network_allow_rules` above, before this loop.
421pub(crate) fn resolve_network_allowlist_ips(
422 network_allow: &[String],
423) -> Result<Vec<IpAddr>, String> {
424 let rules = crate::network_policy::parse_network_allow_rules(network_allow)
425 .map_err(|error| error.to_string())?;
426
427 let mut ips = std::collections::BTreeSet::new();
428 for rule in &rules {
429 let host = rule.host.as_str();
430 match crate::dns_resolver::resolve(host) {
431 crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
432 crate::dns_resolver::Resolution::DoesNotExist => {}
433 // Skipped like any other host that contributed no address, but said out loud: a
434 // resolver that did not answer is a transient condition an operator can act on,
435 // where a name that does not exist is a manifest to correct.
436 crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
437 "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
438 resolved at launch: {reason} (a subprocess reaching it by literal address is \
439 denied for this run)"
440 ),
441 }
442 }
443
444 Ok(ips.into_iter().collect())
445}
446
447/// Resolves each `capabilities.shell.allow` entry to the canonical filesystem path(s) of the
448/// binary it names, once, at launch time. Entries containing a path separator are
449/// canonicalized directly; bare names (the common case, per the manifest schema's
450/// "bare binary name" rule) are looked up in the host `PATH` the way `execvp` would, then
451/// canonicalized (resolving symlinks, `..`, etc.).
452///
453/// Entries that resolve to nothing (binary not installed) are silently skipped — that only
454/// *shrinks* the kernel-level allow set (deny direction, fail-closed) and matches reality:
455/// an exec of a binary that does not exist at launch fails anyway, and a binary *created
456/// after launch* under an allowlisted name (the attack this exists to stop) must not become
457/// allowlisted by virtue of its name.
458///
459/// This canonical set — not the raw name strings — is what `resolve_landlock_grants` derives the
460/// narrow read+execute Landlock grants from, so `cp /usr/bin/nc ./bash && ./bash` is denied: the
461/// copy lives under the workdir, which carries no `Execute` right by default, and the grant that
462/// does carry one names the launch-time `bash`'s own inode, not a basename.
463pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
464 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
465 .map(|path| std::env::split_paths(&path).collect())
466 .unwrap_or_default();
467 resolve_exec_allowlist_in(shell_allow, &path_dirs)
468}
469
470/// Testable core of `resolve_exec_allowlist`, with the `PATH` directory list injected. Every
471/// entry is a bare binary name (the manifest refuses any other, `E-MAN-003`), and every
472/// executable `PATH` match is included (not just the first): all of them are binaries the
473/// operator's launch-time `PATH` exposes under that allowlisted name, and on merged-`/usr` distros
474/// `/bin/x` and `/usr/bin/x` canonicalize to the same entry anyway.
475fn resolve_exec_allowlist_in(shell_allow: &[String], path_dirs: &[PathBuf]) -> Vec<PathBuf> {
476 let mut resolved = std::collections::BTreeSet::new();
477 for entry in shell_allow {
478 if entry.is_empty() {
479 continue;
480 }
481 for dir in path_dirs {
482 let candidate = dir.join(entry);
483 if is_executable_file(&candidate) {
484 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
485 resolved.insert(canonical);
486 }
487 }
488 }
489 }
490 resolved.into_iter().collect()
491}
492
493/// Resolves the program name a shell tool invoked to the canonical filesystem path of the
494/// binary that `execvp` would actually run, for reporting purposes: `shell-event.binary`
495/// on the hook side and the `binary` key of `trace.jsonl`'s `shell` record.
496///
497/// Unlike [`resolve_exec_allowlist`] — which collects *every* `PATH` match because each one needs
498/// its own Landlock grant — this returns the **first** match, which is the one `execvp` picks and
499/// therefore the one that ran. Resolution rules are otherwise identical (same `PATH` source, same
500/// [`is_executable_file`] test, same `canonicalize`), so a reported path is always a member of the
501/// set the Landlock grants were derived from.
502///
503/// A name that resolves to nothing falls back to the bare invoked name, unchanged — never
504/// an error, never a panic, matching `resolve_exec_allowlist`'s "entries that resolve to
505/// nothing are silently skipped" precedent. Observability must not be able to fail a shell
506/// call that the OS is perfectly willing to run.
507pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
508 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
509 .map(|path| std::env::split_paths(&path).collect())
510 .unwrap_or_default();
511 resolve_invoked_binary_path_in(binary, &path_dirs)
512}
513
514/// Testable core of [`resolve_invoked_binary_path`], with the `PATH` directory list
515/// injected. `pub(crate)` because [`crate::reachability`] needs the same first-match resolution
516/// against an *injected* directory list — its own unit tests point `PATH` at a temp directory of
517/// synthetic script fixtures rather than at whatever the machine running the suite has installed.
518pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
519 let fallback = || binary.to_string();
520 if binary.is_empty() {
521 return fallback();
522 }
523 if binary.contains('/') {
524 return match std::fs::canonicalize(binary) {
525 Ok(canonical) => canonical.to_string_lossy().into_owned(),
526 Err(_) => fallback(),
527 };
528 }
529 for dir in path_dirs {
530 let candidate = dir.join(binary);
531 if is_executable_file(&candidate) {
532 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
533 return canonical.to_string_lossy().into_owned();
534 }
535 }
536 }
537 fallback()
538}
539
540/// The first executable named `binary` on this process's `PATH`, or `None` when there is none.
541///
542/// First match wins, which is the one `execvp` would run — the same rule
543/// [`resolve_invoked_binary_path_in`] reports under, so a caller naming a binary and a caller
544/// reporting one cannot disagree about which file `PATH` means. The path is returned as found,
545/// not canonicalized: callers here name a binary to a person rather than derive a grant from it.
546pub fn find_on_path(binary: &str) -> Option<PathBuf> {
547 std::env::split_paths(&std::env::var_os("PATH")?)
548 .map(|dir| dir.join(binary))
549 .find(|candidate| is_executable_file(candidate))
550}
551
552pub(crate) fn is_executable_file(path: &Path) -> bool {
553 use std::os::unix::fs::PermissionsExt;
554 std::fs::metadata(path)
555 .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
556 .unwrap_or(false)
557}
558
559// ---- derived Landlock read+execute grant set ----
560//
561// On `KernelFull`, Landlock scopes the shell subprocess tree to the workdir. The workdir rule
562// grants `linux_enforce::workdir_access_rights` (every right of the ABI the ruleset is built at
563// except device-node and unix-socket creation, and `Execute` unless `workdir_exec`), but — once
564// `restrict_self()` lands — `Execute`/`ReadFile` are then denied on every path *outside* the
565// workdir, including the `shell.allow` binaries themselves, their ELF interpreter (dynamic loader),
566// and every shared library they pull in. That silently defeats the seccomp exec-allowlist: each
567// allowlisted `execve` fails with EACCES before it runs.
568//
569// The functions below derive the exact extra paths that must get a narrow read+execute grant
570// outside the workdir: the canonical `shell.allow` binaries (already resolved by
571// `resolve_exec_allowlist`), each one's `PT_INTERP`, and the transitive closure of the shared
572// libraries their ELF `DT_NEEDED` entries name (resolved via `DT_RPATH`/`DT_RUNPATH` first, then a
573// fixed set of standard library directories). Nothing broader — no directory is granted wholesale.
574//
575// All of this is *parsing and filesystem resolution*, so it runs in the parent at launch time
576// (inside `ShellEnforcement::resolve`) and the resulting `Vec<PathBuf>` is threaded into the
577// forked child's `pre_exec`, which only opens each path and adds a Landlock rule (no parsing in
578// the async-signal-safe window). Every resolution step is shrink-not-fail, matching
579// `resolve_exec_allowlist`: an entry that does not exist, does not parse as ELF, or names a
580// soname that does not resolve simply contributes nothing further — it never errors out.
581
582/// Standard Linux shared-library search directories used as the fallback resolution set for a
583/// `DT_NEEDED` soname when the binary declares no `DT_RPATH`/`DT_RUNPATH` (or it doesn't match).
584/// Covers both supported platform targets (`linux/x86_64` and `linux/aarch64`), including the
585/// Debian/Ubuntu multiarch-triplet subdirectories. Extend here for a platform this list does not
586/// cover — it is the single source of truth.
587const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
588 "/lib",
589 "/lib64",
590 "/usr/lib",
591 "/usr/lib64",
592 "/lib/x86_64-linux-gnu",
593 "/usr/lib/x86_64-linux-gnu",
594 "/lib/aarch64-linux-gnu",
595 "/usr/lib/aarch64-linux-gnu",
596];
597
598/// The subset of an ELF binary's dynamic-linking metadata that decides which extra files the
599/// dynamic loader will touch at load time: the interpreter path (`PT_INTERP`), the direct
600/// shared-library dependencies (`DT_NEEDED` sonames), and the runtime library search paths
601/// (`DT_RPATH`/`DT_RUNPATH`, already split on `:`, `$ORIGIN` left unexpanded here).
602#[derive(Debug, Default, PartialEq, Eq)]
603pub(crate) struct ElfDependencies {
604 pub(crate) interp: Option<String>,
605 pub(crate) needed: Vec<String>,
606 pub(crate) runpaths: Vec<String>,
607}
608
609fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
610 let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
611 Some(if le {
612 u16::from_le_bytes(raw)
613 } else {
614 u16::from_be_bytes(raw)
615 })
616}
617
618fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
619 let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
620 Some(if le {
621 u32::from_le_bytes(raw)
622 } else {
623 u32::from_be_bytes(raw)
624 })
625}
626
627fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
628 let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
629 Some(if le {
630 u64::from_le_bytes(raw)
631 } else {
632 u64::from_be_bytes(raw)
633 })
634}
635
636/// Reads a NUL-terminated string starting at the beginning of `region` (used for both the
637/// `PT_INTERP` segment and dynamic-string-table entries).
638fn cstr_at_start(region: &[u8]) -> String {
639 let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
640 String::from_utf8_lossy(®ion[..nul]).into_owned()
641}
642
643/// Maps a virtual address into a file offset using the ELF's `PT_LOAD` segments — required
644/// because `DT_STRTAB` records the string table's virtual address, not its file offset.
645fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
646 for &(seg_vaddr, seg_off, seg_filesz) in loads {
647 if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
648 return Some(seg_off + (vaddr - seg_vaddr));
649 }
650 }
651 None
652}
653
654/// Pure ELF64 parser: extracts the dynamic-linking metadata that determines which extra files the
655/// loader touches. Returns `None` for anything that is not a well-formed ELF64 image (bad magic,
656/// 32-bit, truncated headers) — a non-ELF or unparseable binary simply contributes nothing to the
657/// grant set (shrink-not-fail). Deliberately hand-rolled (no crate dependency) and byte-slice-in /
658/// struct-out so it is fully unit-testable off-Linux with synthetic fixtures; it makes no syscalls.
659///
660/// Only ELF64 is parsed: both supported Linux targets (`x86_64`, `aarch64`) are 64-bit, so a
661/// 32-bit image is not a binary this runtime would exec and is treated as unparseable.
662pub(crate) fn parse_elf_dependencies(bytes: &[u8]) -> Option<ElfDependencies> {
663 // e_ident (16) + fixed ELF64 header fields extend to offset 64.
664 if bytes.len() < 64 || &bytes[0..4] != b"\x7fELF" {
665 return None;
666 }
667 // EI_CLASS: 2 == ELFCLASS64. EI_DATA: 1 == little-endian, 2 == big-endian.
668 if bytes[4] != 2 {
669 return None;
670 }
671 let le = match bytes[5] {
672 1 => true,
673 2 => false,
674 _ => return None,
675 };
676
677 let e_phoff = read_u64(bytes, 32, le)? as usize;
678 let e_phentsize = read_u16(bytes, 54, le)? as usize;
679 let e_phnum = read_u16(bytes, 56, le)? as usize;
680 // Each Elf64_Phdr is 56 bytes; a smaller entsize is not this layout.
681 if e_phentsize < 56 {
682 return None;
683 }
684
685 let mut interp: Option<String> = None;
686 let mut dynamic_region: Option<(u64, u64)> = None; // (file offset, size)
687 let mut loads: Vec<(u64, u64, u64)> = Vec::new(); // (p_vaddr, p_offset, p_filesz)
688
689 for i in 0..e_phnum {
690 let base = e_phoff.checked_add(i.checked_mul(e_phentsize)?)?;
691 let p_type = read_u32(bytes, base, le)?;
692 let p_offset = read_u64(bytes, base + 8, le)?;
693 let p_vaddr = read_u64(bytes, base + 16, le)?;
694 let p_filesz = read_u64(bytes, base + 32, le)?;
695 match p_type {
696 1 => loads.push((p_vaddr, p_offset, p_filesz)), // PT_LOAD
697 2 => dynamic_region = Some((p_offset, p_filesz)), // PT_DYNAMIC
698 3 => {
699 // PT_INTERP: a NUL-terminated interpreter path in the file image.
700 let start = p_offset as usize;
701 let end = start.checked_add(p_filesz as usize)?;
702 interp = Some(cstr_at_start(bytes.get(start..end)?));
703 }
704 _ => {}
705 }
706 }
707
708 // A static binary (no PT_DYNAMIC) contributes only itself — no interp, no needed libs.
709 let Some((dyn_off, dyn_size)) = dynamic_region else {
710 return Some(ElfDependencies {
711 interp,
712 needed: Vec::new(),
713 runpaths: Vec::new(),
714 });
715 };
716
717 let mut needed_offsets: Vec<u64> = Vec::new();
718 let mut runpath_offsets: Vec<u64> = Vec::new();
719 let mut strtab_vaddr: Option<u64> = None;
720 let mut strsz: Option<u64> = None;
721
722 let mut off = dyn_off as usize;
723 let dyn_end = off.checked_add(dyn_size as usize)?;
724 while off + 16 <= dyn_end {
725 let d_tag = read_u64(bytes, off, le)?;
726 let d_val = read_u64(bytes, off + 8, le)?;
727 match d_tag {
728 0 => break, // DT_NULL — end of dynamic array
729 1 => needed_offsets.push(d_val), // DT_NEEDED
730 5 => strtab_vaddr = Some(d_val), // DT_STRTAB (virtual address)
731 10 => strsz = Some(d_val), // DT_STRSZ
732 15 | 29 => runpath_offsets.push(d_val), // DT_RPATH | DT_RUNPATH
733 _ => {}
734 }
735 off += 16;
736 }
737
738 let mut needed = Vec::new();
739 let mut runpaths = Vec::new();
740 if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
741 if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
742 let start = strtab_off as usize;
743 // Prefer the declared string-table bounds, but tolerate an over-long DT_STRSZ by
744 // falling back to the rest of the file rather than dropping every soname.
745 let strtab = start
746 .checked_add(strsz as usize)
747 .and_then(|end| bytes.get(start..end))
748 .or_else(|| bytes.get(start..));
749 if let Some(strtab) = strtab {
750 for offset in needed_offsets {
751 if let Some(region) = strtab.get(offset as usize..) {
752 needed.push(cstr_at_start(region));
753 }
754 }
755 for offset in runpath_offsets {
756 if let Some(region) = strtab.get(offset as usize..) {
757 for part in cstr_at_start(region).split(':') {
758 if !part.is_empty() {
759 runpaths.push(part.to_string());
760 }
761 }
762 }
763 }
764 }
765 }
766 }
767
768 Some(ElfDependencies {
769 interp,
770 needed,
771 runpaths,
772 })
773}
774
775/// Expands a leading/embedded `$ORIGIN` (or `${ORIGIN}`) in an rpath entry to the directory of the
776/// binary that declared it — the dynamic loader's own rule for `DT_RUNPATH`/`DT_RPATH`.
777fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
778 if rpath.contains("$ORIGIN") {
779 let origin = origin_dir.to_string_lossy();
780 PathBuf::from(
781 rpath
782 .replace("${ORIGIN}", &origin)
783 .replace("$ORIGIN", &origin),
784 )
785 } else {
786 PathBuf::from(rpath)
787 }
788}
789
790/// Resolves one `DT_NEEDED` soname to a canonical file path, mirroring the loader's search order:
791/// a soname containing a `/` is taken as a path (absolute, or relative to the binary's directory);
792/// otherwise the binary's own `DT_RUNPATH`/`DT_RPATH` entries (with `$ORIGIN` expanded) are tried
793/// first, then the standard `search_dirs`. Returns `None` (shrink-not-fail) if nothing matches.
794pub(crate) fn resolve_soname(
795 soname: &str,
796 origin_dir: &Path,
797 runpaths: &[String],
798 search_dirs: &[PathBuf],
799) -> Option<PathBuf> {
800 if soname.is_empty() {
801 return None;
802 }
803 if soname.contains('/') {
804 let candidate = if Path::new(soname).is_absolute() {
805 PathBuf::from(soname)
806 } else {
807 origin_dir.join(soname)
808 };
809 return std::fs::canonicalize(&candidate).ok();
810 }
811 for rpath in runpaths {
812 let candidate = expand_origin(rpath, origin_dir).join(soname);
813 if candidate.is_file() {
814 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
815 return Some(canonical);
816 }
817 }
818 }
819 for dir in search_dirs {
820 let candidate = dir.join(soname);
821 if candidate.is_file() {
822 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
823 return Some(canonical);
824 }
825 }
826 }
827 None
828}
829
830/// Derives the full set of canonical file paths that need a narrow read+execute Landlock grant
831/// *outside* the workdir so the `shell.allow` binaries can actually run: each allowlisted binary,
832/// its ELF interpreter, and the transitive closure of its `DT_NEEDED` shared libraries. Uses the
833/// standard [`DEFAULT_LIBRARY_SEARCH_DIRS`] as the soname fallback search set.
834///
835/// Every step is shrink-not-fail (a binary that can't be read/parsed, or a soname that doesn't
836/// resolve, contributes nothing further); the returned set is deduplicated and canonical. Runs in
837/// the parent at launch time — never in the forked child's `pre_exec`.
838pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
839 let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
840 .iter()
841 .map(PathBuf::from)
842 .collect();
843 resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
844}
845
846/// Testable core of [`resolve_landlock_grants`], with the library search directories injected so
847/// unit tests can point it at synthetic fixtures instead of the real system directories.
848fn resolve_landlock_grants_in(
849 exec_allow_paths: &[PathBuf],
850 search_dirs: &[PathBuf],
851) -> Vec<PathBuf> {
852 resolve_landlock_grants_outside(exec_allow_paths, search_dirs, None)
853}
854
855/// [`resolve_landlock_grants_in`], skipping every path under the canonical `excluded` root: such a
856/// path is neither granted nor read for further dependencies. For a closure that must not be
857/// decided by files the capsule can write.
858fn resolve_landlock_grants_outside(
859 exec_allow_paths: &[PathBuf],
860 search_dirs: &[PathBuf],
861 excluded: Option<&Path>,
862) -> Vec<PathBuf> {
863 let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
864 let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
865 let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
866
867 for path in exec_allow_paths {
868 // The allowlist paths are already canonical/existing (from `resolve_exec_allowlist`), but
869 // canonicalize defensively — this both makes dedup against interp/library paths exact and
870 // drops any seed that no longer exists (shrink-not-fail: a vanished binary contributes
871 // nothing, and could not be exec'd anyway).
872 if let Ok(canonical) = std::fs::canonicalize(path) {
873 queue.push_back(canonical);
874 }
875 }
876
877 while let Some(path) = queue.pop_front() {
878 if excluded.is_some_and(|root| path.starts_with(root)) {
879 continue;
880 }
881 if !visited.insert(path.clone()) {
882 continue;
883 }
884 // The binary/library itself is granted. If it lives inside the workdir, the workdir's own
885 // full-access rule already covers it and this extra read+execute rule is simply redundant.
886 grants.insert(path.clone());
887
888 let Ok(bytes) = std::fs::read(&path) else {
889 continue;
890 };
891 let Some(deps) = parse_elf_dependencies(&bytes) else {
892 continue;
893 };
894 let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
895
896 if let Some(interp) = &deps.interp {
897 if let Ok(canonical) = std::fs::canonicalize(interp) {
898 queue.push_back(canonical);
899 }
900 }
901 for soname in &deps.needed {
902 if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
903 {
904 queue.push_back(resolved);
905 }
906 }
907 }
908
909 grants.into_iter().collect()
910}
911
912/// One resolved Landlock filesystem grant outside the workdir: a canonical path plus the two
913/// independent bits that decide the rule's access set.
914///
915/// `list_dir` decides enumerability (`ReadDir`):
916///
917/// - `false` → no `getdents64` on the granted directory itself. Landlock's read rights apply to
918/// the whole subtree beneath a granted directory, so a file *inside* it can still be opened by
919/// its exact name — the listing is what is denied. This is what every derived
920/// `DT_NEEDED`-closure grant gets — they are individual files, where `ReadDir` was always a
921/// no-op anyway (Landlock's `ReadDir` only has meaning on a directory inode).
922/// - `true` → adds enumerability, which a path-based interpreter's import machinery needs on
923/// each `sys.path` entry (CPython's `FileFinder` `listdir`-caches each one). Set by an author
924/// writing `list_dir: true` next to a specific `interpreter_runtime` directory, and by the
925/// whole-tree resolvers ([`resolve_staged_runtime_landlock_grants`],
926/// [`resolve_sealed_runtime_landlock_grants`] and, per entry, the directory half of
927/// [`resolve_sealed_etc_landlock_grants`]) where a tree is walked by definition — never
928/// inferred from anything else, and never applied to an ancestor or sibling of a granted
929/// directory.
930///
931/// `ReadDir` is meaningful only on a directory, and the kernel enforces that: `landlock_add_rule`
932/// returns `EINVAL` for a directory-only right on a non-directory. `open_landlock_fds` therefore
933/// narrows `list_dir` to `false` for any grant whose fd does not `fstat` as a directory, so this
934/// field is a request rather than a promise.
935///
936/// `executable` decides `Execute`, which is this runtime's exec allowlist: a path without an
937/// `Execute` rule cannot be `execve`d, and a path with one can. This is the canonical statement of
938/// that rule; the grant resolvers below cross-reference it rather than restating it.
939///
940/// - `true` → `Execute + ReadFile`, for a path the manifest asked to *run*: a `shell.allow`
941/// binary, its `DT_NEEDED` closure, and the interpreter/staged trees an author named.
942/// - `false` → `ReadFile` only (readable, and with `list_dir` enumerable, but not runnable).
943/// For a grant the *tier* issues rather than the manifest: see
944/// [`resolve_sealed_runtime_landlock_grants`], where granting `Execute` over `/usr`, `/bin`
945/// and `/sbin` wholesale would turn `shell.allow` into a no-op on `sealed`, and
946/// [`resolve_sealed_etc_landlock_grants`], where `/etc/alternatives` is a directory of
947/// symlinks into exactly those trees.
948///
949/// Affects `execve` only: a shared object mapped `PROT_EXEC` by `dlopen(3)` needs `ReadFile`, not
950/// `Execute`, so an interpreter still loads its C extensions out of an `executable: false` tree.
951#[derive(Debug, Clone, PartialEq, Eq)]
952pub(crate) struct LandlockGrant {
953 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
954 pub(crate) path: PathBuf,
955 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
956 pub(crate) list_dir: bool,
957 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
958 pub(crate) executable: bool,
959 /// When set, the rule is added only if `path` still opens to this inode, and is otherwise
960 /// dropped (shrink-not-fail). For a grant on a capsule-writable path: the capsule can replace
961 /// the file, or a directory above it, with a symlink to any host binary, and an unpinned grant
962 /// would follow it and put `Execute` on that binary. A pin tells one live file from another,
963 /// not new content from old: an inode number freed by a delete can be reused by the next file.
964 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
965 pub(crate) pinned: Option<FileIdentity>,
966}
967
968/// A file's `(st_dev, st_ino)`: which inode a path named when the runtime recorded it.
969#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub(crate) struct FileIdentity {
971 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
972 dev: u64,
973 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
974 ino: u64,
975}
976
977impl FileIdentity {
978 #[cfg(unix)]
979 pub(crate) fn of(metadata: &std::fs::Metadata) -> FileIdentity {
980 use std::os::unix::fs::MetadataExt;
981 FileIdentity {
982 dev: metadata.dev(),
983 ino: metadata.ino(),
984 }
985 }
986}
987
988impl LandlockGrant {
989 /// Wraps each derived `DT_NEEDED`-closure file path as a non-listable grant. A regular file
990 /// has no meaningful `ReadDir`, so `list_dir: false` costs nothing. `executable: true`: this
991 /// closure is exactly the set of paths `shell.allow` asked to run — see [`LandlockGrant`].
992 fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
993 paths
994 .into_iter()
995 .map(|path| LandlockGrant {
996 path,
997 list_dir: false,
998 executable: true,
999 pinned: None,
1000 })
1001 .collect()
1002 }
1003}
1004
1005/// Turns each `capabilities.shell.interpreter_runtime` directory into a [`LandlockGrant`] carrying
1006/// exactly the `list_dir` its author wrote. Pure and syscall-free (the declared paths need not
1007/// exist here — `apply_landlock_scope` skips any that fail to open), so it is unit-testable on
1008/// every platform, including non-Linux targets.
1009///
1010/// There is deliberately no path here that could widen a grant to a whole install prefix: it emits
1011/// one grant per explicitly declared directory and copies the author's `list_dir` verbatim,
1012/// inferring enumerability from nothing.
1013pub(crate) fn resolve_interpreter_runtime_grants(
1014 grants: &[InterpreterRuntimeGrant],
1015) -> Vec<LandlockGrant> {
1016 grants
1017 .iter()
1018 .flat_map(|grant| {
1019 grant.dirs.iter().map(|dir| LandlockGrant {
1020 path: PathBuf::from(&dir.path),
1021 list_dir: dir.list_dir,
1022 // Executable: an `interpreter_runtime` directory is named by an author precisely so
1023 // the interpreter it belongs to can run out of it, helper binaries included.
1024 executable: true,
1025 pinned: None,
1026 })
1027 })
1028 .collect()
1029}
1030
1031/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
1032/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
1033/// allowlisted binary, and each declared `interpreter_runtime` directory.
1034///
1035/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
1036/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
1037/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
1038/// derivation `scoped` needs has nothing left to do here.
1039///
1040/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
1041/// makes the composed root narrower rather than refusing the launch, which is why
1042/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
1043/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
1044/// required bind. This function is additive to that, not replaced by it.
1045///
1046/// Pure and syscall-free, so it is unit-testable on any platform.
1047pub(crate) fn resolve_sealed_bind_dirs(
1048 exec_allow_paths: &[PathBuf],
1049 policy: &CapabilityPolicy,
1050) -> Vec<PathBuf> {
1051 let mut dirs: Vec<PathBuf> = Vec::new();
1052 let candidates = exec_allow_paths
1053 .iter()
1054 .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1055 .chain(
1056 policy
1057 .shell_interpreter_runtime
1058 .iter()
1059 .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1060 );
1061
1062 for candidate in candidates {
1063 push_sealed_bind_dir(&mut dirs, candidate);
1064 }
1065 dirs
1066}
1067
1068/// Adds `candidate` to `dirs` as an optional composed-root bind unless it is relative, already
1069/// inside a fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, or already listed. The one filter
1070/// [`resolve_sealed_bind_dirs`] and [`ShellEnforcement::for_native_entrypoint`] share.
1071fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1072 if !candidate.is_absolute() {
1073 return;
1074 }
1075 if crate::sealed::SEALED_RUNTIME_PATHS
1076 .iter()
1077 .any(|root| candidate.starts_with(root))
1078 {
1079 return;
1080 }
1081 if dirs.contains(&candidate) {
1082 return;
1083 }
1084 dirs.push(candidate);
1085}
1086
1087/// The most bytes [`shebang_interpreter`] reads from the head of a native entrypoint. The
1088/// kernel's own `#!` buffer (`BINPRM_BUF_SIZE`) is 256 bytes, so a longer line names nothing the
1089/// kernel would run.
1090const SHEBANG_READ_LIMIT: u64 = 256;
1091
1092/// The interpreter the kernel runs when it execs the script `image`: the first token of a leading
1093/// `#!` line, when that token is an absolute path. `None` for an ELF binary, a relative
1094/// interpreter and a line with no token.
1095///
1096/// Looks at no more than the first [`SHEBANG_READ_LIMIT`] bytes. Only the interpreter itself is
1097/// named: for `#!/usr/bin/env python3` that is `env`, and `python3` is whatever `env` then finds,
1098/// which needs its own `capabilities.shell.allow` entry like any other program a tool runs.
1099pub(crate) fn shebang_interpreter(image: &[u8]) -> Option<PathBuf> {
1100 let head = &image[..image.len().min(SHEBANG_READ_LIMIT as usize)];
1101 let line = crate::reachability::shebang_line_of(head)?;
1102 let interpreter = PathBuf::from(line.split_whitespace().next()?);
1103 interpreter.is_absolute().then_some(interpreter)
1104}
1105
1106/// What the kernel executes to start one installed native tool, derived once, when the runtime
1107/// writes the tool into the session workdir, from the image bytes it writes.
1108///
1109/// The workdir is capsule-writable, so nothing here is read back from it: the `#!` interpreter and
1110/// the `PT_INTERP`/`DT_NEEDED` entries come from the runtime's own copy of the image, the
1111/// dependency walk searches only absolute locations and neither grants nor reads a file under the
1112/// accessible workdir, and the entrypoint's own grant is pinned to the inode the runtime wrote. A
1113/// symlink or any other existing file put in the entrypoint's place loses its `Execute` rule. A
1114/// capsule that rewrites the entrypoint runs its own code under these grants, including when it
1115/// deletes the file and the filesystem hands a new one the freed inode number.
1116#[derive(Debug, Clone, PartialEq, Eq)]
1117pub(crate) struct NativeEntrypoint {
1118 path: PathBuf,
1119 pinned: Option<FileIdentity>,
1120 interpreter: Option<PathBuf>,
1121 closure: Vec<PathBuf>,
1122}
1123
1124impl NativeEntrypoint {
1125 /// Derives the grants for the tool the runtime has just written at `path` from `image`, the
1126 /// bytes it wrote. `accessible_workdir` is the capsule-writable tree the walk stays out of.
1127 pub(crate) fn derive(path: &Path, image: &[u8], accessible_workdir: &Path) -> NativeEntrypoint {
1128 let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1129 .iter()
1130 .map(PathBuf::from)
1131 .collect();
1132 Self::derive_in(path, image, accessible_workdir, &search_dirs)
1133 }
1134
1135 fn derive_in(
1136 path: &Path,
1137 image: &[u8],
1138 accessible_workdir: &Path,
1139 search_dirs: &[PathBuf],
1140 ) -> NativeEntrypoint {
1141 let interpreter = shebang_interpreter(image);
1142 let mut seeds: Vec<PathBuf> = interpreter.iter().cloned().collect();
1143 if let Some(deps) = parse_elf_dependencies(image) {
1144 // Only absolute locations are searched. `$ORIGIN` is the entrypoint's own directory in
1145 // the capsule-writable workdir, and a relative entry resolves against this process's
1146 // working directory, which may be the workdir too: a symlink planted there would
1147 // carry the lookup to any host file, and canonicalising it hides that from the
1148 // exclusion below. A library the tool ships beside itself is readable through the
1149 // workdir's own rule and needs no grant.
1150 let absolute = |entry: &&String| Path::new(entry.as_str()).is_absolute();
1151 seeds.extend(deps.interp.iter().filter(absolute).map(PathBuf::from));
1152 let runpaths: Vec<String> = deps
1153 .runpaths
1154 .iter()
1155 .filter(|entry| absolute(entry) && !entry.contains("ORIGIN"))
1156 .cloned()
1157 .collect();
1158 seeds.extend(
1159 deps.needed
1160 .iter()
1161 .filter(|soname| !soname.contains('/') || absolute(soname))
1162 .filter_map(|soname| {
1163 resolve_soname(soname, Path::new("/"), &runpaths, search_dirs)
1164 }),
1165 );
1166 }
1167 let excluded = std::fs::canonicalize(accessible_workdir)
1168 .unwrap_or_else(|_| accessible_workdir.to_path_buf());
1169 NativeEntrypoint {
1170 path: path.to_path_buf(),
1171 pinned: pinned_identity(path),
1172 interpreter,
1173 closure: resolve_landlock_grants_outside(&seeds, search_dirs, Some(&excluded)),
1174 }
1175 }
1176
1177 /// The installed entrypoint, the path the tool is spawned from.
1178 pub(crate) fn path(&self) -> &Path {
1179 &self.path
1180 }
1181}
1182
1183/// The identity of the regular file at `path`, not following a final symlink. `None` when there is
1184/// none, which leaves the entrypoint without a grant rather than with an unpinned one.
1185#[cfg(unix)]
1186fn pinned_identity(path: &Path) -> Option<FileIdentity> {
1187 std::fs::symlink_metadata(path)
1188 .ok()
1189 .filter(std::fs::Metadata::is_file)
1190 .map(|meta| FileIdentity::of(&meta))
1191}
1192
1193#[cfg(not(unix))]
1194fn pinned_identity(_path: &Path) -> Option<FileIdentity> {
1195 None
1196}
1197
1198/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
1199/// root's *required* read-only binds.
1200///
1201/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
1202/// way that function filters. Both differences follow from the same fact — these fail closed:
1203///
1204/// * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
1205/// Dropping it would be sound for an optional bind (the fixed list already covers it) but it
1206/// would quietly convert a required grant into a dependency on an unrelated list staying the
1207/// way it is today. `plan_composed_root` runs the required loop first and deduplicates by
1208/// target, so naming an already-covered path costs one registration and stays required.
1209/// * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
1210/// grant naming a tree this host does not have refuses the launch — see
1211/// `sealed::PlanBuilder::require_bind`.
1212///
1213/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
1214/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
1215/// layer that rejects one.
1216///
1217/// Pure and syscall-free, so it is unit-testable on any platform.
1218pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1219 let mut dirs: Vec<PathBuf> = Vec::new();
1220 for grant in &policy.shell_staged_runtime {
1221 let path = PathBuf::from(&grant.source_path);
1222 if !path.is_absolute() || dirs.contains(&path) {
1223 continue;
1224 }
1225 dirs.push(path);
1226 }
1227 dirs
1228}
1229
1230/// One listable [`LandlockGrant`] per staged runtime directory.
1231///
1232/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
1233/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
1234/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
1235/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
1236/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
1237/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
1238/// working at all.
1239///
1240/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
1241/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
1242/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
1243/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
1244/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
1245/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
1246/// same inode reached through the bind inside the root.
1247///
1248/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
1249/// of two independent reasons the tree cannot be mutated from inside a session.
1250///
1251/// Pure and syscall-free, so it is unit-testable on any platform.
1252pub(crate) fn resolve_staged_runtime_landlock_grants(
1253 policy: &CapabilityPolicy,
1254) -> Vec<LandlockGrant> {
1255 resolve_staged_runtime_dirs(policy)
1256 .into_iter()
1257 .map(|path| LandlockGrant {
1258 path,
1259 list_dir: true,
1260 executable: true,
1261 pinned: None,
1262 })
1263 .collect()
1264}
1265
1266/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1267/// `KernelSealed`.
1268///
1269/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1270/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1271/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1272/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1273/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1274/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1275/// enumerable.
1276///
1277/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1278/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1279///
1280/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1281/// the manifest never named, so it must enumerate them without becoming permission to run them.
1282///
1283/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1284/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1285/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1286/// nothing. Empty on every other tier.
1287///
1288/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1289/// to open), so unit-testable on every platform.
1290pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1291 if tier != EnforcementTier::KernelSealed {
1292 return Vec::new();
1293 }
1294 crate::sealed::SEALED_RUNTIME_PATHS
1295 .iter()
1296 .map(|path| LandlockGrant {
1297 path: PathBuf::from(path),
1298 list_dir: true,
1299 executable: false,
1300 pinned: None,
1301 })
1302 .collect()
1303}
1304
1305/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1306/// `KernelSealed`.
1307///
1308/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1309/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1310/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1311/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1312/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1313/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1314/// fail certificate verification over an otherwise-working TCP connection.
1315///
1316/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1317/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1318/// directories, regular files and symlinks.
1319/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1320/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1321/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1322///
1323/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1324/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1325/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1326/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1327/// prevent.
1328///
1329/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1330/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1331///
1332/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1333/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1334/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1335/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1336/// reading it reveals nothing sensitive. Empty on every other tier.
1337///
1338/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1339/// open), so unit-testable on every platform.
1340pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1341 if tier != EnforcementTier::KernelSealed {
1342 return Vec::new();
1343 }
1344 crate::sealed::SEALED_ETC_PATHS
1345 .iter()
1346 .map(|entry| LandlockGrant {
1347 path: PathBuf::from(entry.path),
1348 list_dir: entry.directory,
1349 executable: false,
1350 pinned: None,
1351 })
1352 .collect()
1353}
1354
1355// ---- fixed capsule device set ------------------------------------------------------------
1356//
1357// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1358// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1359// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1360// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1361// device, and none that takes one away.
1362//
1363// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1364// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1365// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1366// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1367
1368/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1369///
1370/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1371/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1372/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1373/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1374/// `list_dir` into a third meaning.
1375///
1376/// `writable` is the whole reason this type exists as more than a path list:
1377///
1378/// - `false` → `ReadFile` only. The device is readable and nothing more.
1379/// - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, and it is the
1380/// only writable path outside the workdir in the entire sandbox.
1381///
1382/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1383/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1384/// module's Linux-only surface carries.
1385#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1387pub(crate) struct CapsuleDeviceGrant {
1388 pub(crate) path: &'static str,
1389 pub(crate) writable: bool,
1390}
1391
1392/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1393/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1394/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1395/// stays denied by the same mechanism that already denies every other unlisted path: with
1396/// `handle_access` declaring the full right-set of the ABI the ruleset is built at, "no rule" means
1397/// "denied", not "ungoverned".
1398///
1399/// - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1400/// outside the workdir is writable". A read-only grant would be broken, not merely strict:
1401/// bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1402/// shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1403/// and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1404/// write on a character device whose write side is defined to discard everything gives up no
1405/// confidentiality and no integrity — there is no state behind it to reach.
1406/// (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1407/// file is actually created, and `/dev/null` already exists.)
1408/// - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1409/// in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1410/// write there, so it does not get `WriteFile`.
1411/// - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1412/// filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1413/// device outright and fall over if they cannot.
1414///
1415/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1416/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1417/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1418/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1419/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1420/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1421///
1422/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1423/// workload has been observed failing on the missing device, and record that failure in the same
1424/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1425///
1426/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1427/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1428/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1429/// implementation should *retire* this constant, not merge with it.
1430#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1431pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1432 CapsuleDeviceGrant {
1433 path: "/dev/null",
1434 writable: true,
1435 },
1436 CapsuleDeviceGrant {
1437 path: "/dev/zero",
1438 writable: false,
1439 },
1440 CapsuleDeviceGrant {
1441 path: "/dev/urandom",
1442 writable: false,
1443 },
1444];
1445
1446/// Network decision for one destination IP.
1447/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1448/// no reason to open any subprocess socket).
1449#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1450pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1451 network_allow_ips.contains(&ip)
1452}
1453
1454// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1455// rather than `libc::AF_*`: the filter these feed is
1456// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1457// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1458// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1459// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1460// of the three from `libc` and two from literals would be worse than taking all three the same
1461// way. Values are stable kernel ABI: `include/linux/socket.h`.
1462const LINUX_AF_UNIX: i32 = 1;
1463const LINUX_AF_INET: i32 = 2;
1464const LINUX_AF_INET6: i32 = 10;
1465const LINUX_AF_NETLINK: i32 = 16;
1466const LINUX_AF_PACKET: i32 = 17;
1467
1468/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1469/// `capabilities.network.unix_sockets` declaration.
1470///
1471/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1472/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1473/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1474///
1475/// Why a domain check is a classic BPF rule and not a notify:
1476/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1477/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1478/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1479/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1480/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1481/// pointed-to argument out of another task's memory invites.
1482///
1483/// The three families, and why they are not symmetric:
1484/// - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1485/// by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1486/// need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1487/// Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1488/// - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1489/// capture and injection) are denied unconditionally, with no manifest key to widen them.
1490/// Neither has a shell-tool use case comparable to a local daemon socket, and both are
1491/// strictly more dangerous than one. Widening them would be a deliberate future decision with
1492/// its own justification, not an oversight to be fixed by adding a flag here.
1493///
1494/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1495/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1496/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1497/// get a positive rule — since the filter's default action is a deny, a family named by neither
1498/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1499#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1500pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1501 let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1502 if !unix_sockets_allowed {
1503 denied.push(LINUX_AF_UNIX);
1504 }
1505 denied
1506}
1507
1508/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1509/// `capabilities.network.unix_sockets` declaration. The exact complement of
1510/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1511///
1512/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1513/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1514/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1515/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1516/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1517/// equality-matched sets that a test can check against each other.
1518///
1519/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1520/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1521/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1522/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1523/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1524/// reason) if a real workload needs it.
1525#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1526pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1527 let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1528 if unix_sockets_allowed {
1529 allowed.push(LINUX_AF_UNIX);
1530 }
1531 allowed
1532}
1533
1534/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1535/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1536///
1537/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1538/// reason: the filter is always compiled for a Linux child.
1539#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1540const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1541#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1542const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1543
1544/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1545/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1546/// is the maintained source of the same list Docker ships).
1547///
1548/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1549/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1550///
1551/// - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1552/// [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1553///
1554/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1555/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1556/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1557/// child's very first act after this filter loads is the `execve` that turns it into the tool
1558/// binary, so a denial here refuses every capsule outright.
1559///
1560/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1561/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1562/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1563/// `io_uring` is the one that
1564/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1565/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1566/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1567///
1568/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1569/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1570/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1571/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1572/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1573/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1574#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1575pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1576 // ---- process / thread lifecycle ----
1577 // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1578 // doc comment for the mechanism that replaced their former `Notify` rules.
1579 "execve",
1580 "execveat",
1581 // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1582 // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1583 // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1584 // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1585 // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1586 // largely inert.
1587 "clone",
1588 "clone3",
1589 "fork",
1590 "vfork",
1591 "exit",
1592 "exit_group",
1593 "wait4",
1594 "waitid",
1595 "kill",
1596 "tkill",
1597 "tgkill",
1598 "rt_sigaction",
1599 "rt_sigprocmask",
1600 "rt_sigreturn",
1601 "rt_sigsuspend",
1602 "rt_sigpending",
1603 "rt_sigtimedwait",
1604 "rt_sigqueueinfo",
1605 "rt_tgsigqueueinfo",
1606 "sigaltstack",
1607 // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1608 // would break every `sleep`/`read` that takes a signal.
1609 "restart_syscall",
1610 "pause",
1611 "set_tid_address",
1612 "set_robust_list",
1613 "get_robust_list",
1614 // glibc 2.35+ registers a restartable sequence on every thread start.
1615 "rseq",
1616 "prctl",
1617 "arch_prctl",
1618 "getpid",
1619 "getppid",
1620 "gettid",
1621 "getpgrp",
1622 "getpgid",
1623 "setpgid",
1624 "getsid",
1625 "setsid",
1626 "getpriority",
1627 "setpriority",
1628 "ioprio_get",
1629 "ioprio_set",
1630 "sched_yield",
1631 "sched_getaffinity",
1632 "sched_setaffinity",
1633 "sched_getparam",
1634 "sched_setparam",
1635 "sched_getscheduler",
1636 "sched_setscheduler",
1637 "sched_getattr",
1638 "sched_setattr",
1639 "sched_get_priority_max",
1640 "sched_get_priority_min",
1641 "sched_rr_get_interval",
1642 "getcpu",
1643 "capget",
1644 "capset",
1645 "prlimit64",
1646 "getrlimit",
1647 "setrlimit",
1648 "getrusage",
1649 "getitimer",
1650 "setitimer",
1651 "times",
1652 "uname",
1653 "sysinfo",
1654 // pidfd process handles: they only ever address a process the caller could already signal.
1655 "pidfd_open",
1656 "pidfd_send_signal",
1657 // ---- memory ----
1658 "mmap",
1659 "munmap",
1660 "mprotect",
1661 "mremap",
1662 "madvise",
1663 "brk",
1664 "mlock",
1665 "mlock2",
1666 "munlock",
1667 "mlockall",
1668 "munlockall",
1669 "membarrier",
1670 "msync",
1671 "mincore",
1672 "memfd_create",
1673 "pkey_alloc",
1674 "pkey_free",
1675 "pkey_mprotect",
1676 "shmget",
1677 "shmat",
1678 "shmdt",
1679 "shmctl",
1680 // ---- file I/O ----
1681 "open",
1682 "openat",
1683 "openat2",
1684 "creat",
1685 "close",
1686 "close_range",
1687 "read",
1688 "write",
1689 "pread64",
1690 "pwrite64",
1691 "readv",
1692 "writev",
1693 "preadv",
1694 "pwritev",
1695 "preadv2",
1696 "pwritev2",
1697 "lseek",
1698 "dup",
1699 "dup2",
1700 "dup3",
1701 "fcntl",
1702 "flock",
1703 "fsync",
1704 "fdatasync",
1705 "sync",
1706 "syncfs",
1707 "sync_file_range",
1708 "fadvise64",
1709 "readahead",
1710 "truncate",
1711 "ftruncate",
1712 "fallocate",
1713 "stat",
1714 "fstat",
1715 "lstat",
1716 "newfstatat",
1717 "statx",
1718 "access",
1719 "faccessat",
1720 "faccessat2",
1721 "getdents",
1722 "getdents64",
1723 "getcwd",
1724 "chdir",
1725 "fchdir",
1726 "mkdir",
1727 "mkdirat",
1728 "rmdir",
1729 "unlink",
1730 "unlinkat",
1731 "rename",
1732 "renameat",
1733 "renameat2",
1734 "link",
1735 "linkat",
1736 "symlink",
1737 "symlinkat",
1738 "readlink",
1739 "readlinkat",
1740 "chmod",
1741 "fchmod",
1742 "fchmodat",
1743 // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
1744 // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
1745 "fchmodat2",
1746 "chown",
1747 "fchown",
1748 "fchownat",
1749 "lchown",
1750 "umask",
1751 "utime",
1752 "utimes",
1753 "utimensat",
1754 "futimesat",
1755 // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
1756 // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
1757 // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
1758 "mknod",
1759 "mknodat",
1760 "sendfile",
1761 "copy_file_range",
1762 "splice",
1763 "tee",
1764 "vmsplice",
1765 "ioctl",
1766 "poll",
1767 "ppoll",
1768 "select",
1769 "pselect6",
1770 "epoll_create",
1771 "epoll_create1",
1772 "epoll_ctl",
1773 "epoll_wait",
1774 "epoll_pwait",
1775 "epoll_pwait2",
1776 "eventfd",
1777 "eventfd2",
1778 "pipe",
1779 "pipe2",
1780 "inotify_init",
1781 "inotify_init1",
1782 "inotify_add_watch",
1783 "inotify_rm_watch",
1784 "signalfd",
1785 "signalfd4",
1786 "timerfd_create",
1787 "timerfd_settime",
1788 "timerfd_gettime",
1789 "getxattr",
1790 "lgetxattr",
1791 "fgetxattr",
1792 "setxattr",
1793 "lsetxattr",
1794 "fsetxattr",
1795 "listxattr",
1796 "llistxattr",
1797 "flistxattr",
1798 "removexattr",
1799 "lremovexattr",
1800 "fremovexattr",
1801 "statfs",
1802 "fstatfs",
1803 // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
1804 // into an open file, ignoring the path it was reached by) stays denied.
1805 "name_to_handle_at",
1806 // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
1807 // path resolution of its own, so it carries none of the LSM-bypass property that got the
1808 // `io_uring_*` family denied.
1809 "io_setup",
1810 "io_destroy",
1811 "io_submit",
1812 "io_cancel",
1813 "io_getevents",
1814 // ---- sockets ----
1815 // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
1816 //
1817 // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
1818 // capsule's own network namespace has no route to anything except the egress proxy. The
1819 // destination check lives there, not in this filter — see `crate::network_namespace`.
1820 "connect",
1821 "sendto",
1822 "socketpair",
1823 "bind",
1824 "listen",
1825 "accept",
1826 "accept4",
1827 "getsockname",
1828 "getpeername",
1829 "getsockopt",
1830 "setsockopt",
1831 "shutdown",
1832 "sendmsg",
1833 "sendmmsg",
1834 "recvfrom",
1835 "recvmsg",
1836 "recvmmsg",
1837 // ---- time ----
1838 "clock_gettime",
1839 "clock_getres",
1840 "clock_nanosleep",
1841 "nanosleep",
1842 "gettimeofday",
1843 "time",
1844 "timer_create",
1845 "timer_settime",
1846 "timer_gettime",
1847 "timer_getoverrun",
1848 "timer_delete",
1849 "alarm",
1850 // ---- System V / POSIX IPC ----
1851 "msgget",
1852 "msgsnd",
1853 "msgrcv",
1854 "msgctl",
1855 "semget",
1856 "semop",
1857 "semctl",
1858 "semtimedop",
1859 "mq_open",
1860 "mq_unlink",
1861 "mq_timedsend",
1862 "mq_timedreceive",
1863 "mq_notify",
1864 "mq_getsetattr",
1865 // ---- misc ----
1866 "getrandom",
1867 "futex",
1868 "futex_waitv",
1869 "getuid",
1870 "geteuid",
1871 "getgid",
1872 "getegid",
1873 "setuid",
1874 "setgid",
1875 "setreuid",
1876 "setregid",
1877 "setresuid",
1878 "setresgid",
1879 "getresuid",
1880 "getresgid",
1881 "getgroups",
1882 "setgroups",
1883 "setfsuid",
1884 "setfsgid",
1885];
1886
1887/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
1888/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
1889/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
1890/// allowlist while reconciling it against a newer upstream profile.
1891///
1892/// Three groups, and they are not equally negotiable:
1893///
1894/// 1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
1895/// them: `io_uring_setup`/`io_uring_enter`/
1896/// `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
1897/// around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
1898/// `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
1899/// (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
1900/// `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
1901/// `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
1902/// deliberately stricter there.
1903/// 2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
1904/// mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
1905/// clock setting, NUMA policy, `fanotify`.
1906/// 3. **Denied instead of argument-conditioned.** Docker allows these only for specific
1907/// argument values, which a per-syscall-name allowlist cannot express and which would
1908/// otherwise have to be routed through a userspace decision this filter no longer makes.
1909/// Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
1910/// standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
1911/// itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
1912// The list is a specification, not an input to the filter build: its one consumer is the
1913// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
1914// the thing that test checks against.
1915#[allow(dead_code)]
1916pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
1917 // 1. demonstrated reachable on bare metal
1918 "io_uring_setup",
1919 "io_uring_enter",
1920 "io_uring_register",
1921 "bpf",
1922 "userfaultfd",
1923 "perf_event_open",
1924 "process_vm_readv",
1925 "process_vm_writev",
1926 "open_by_handle_at",
1927 "keyctl",
1928 "add_key",
1929 "request_key",
1930 "ptrace",
1931 // 2. privileged / host-state
1932 "mount",
1933 "umount2",
1934 "pivot_root",
1935 "chroot",
1936 "unshare",
1937 "setns",
1938 "init_module",
1939 "finit_module",
1940 "delete_module",
1941 "kexec_load",
1942 "kexec_file_load",
1943 "reboot",
1944 "swapon",
1945 "swapoff",
1946 "acct",
1947 "quotactl",
1948 "nfsservctl",
1949 "iopl",
1950 "ioperm",
1951 "vm86",
1952 "vm86old",
1953 "_sysctl",
1954 "sysfs",
1955 "uselib",
1956 "create_module",
1957 "get_kernel_syms",
1958 "query_module",
1959 "lookup_dcookie",
1960 "clock_adjtime",
1961 "clock_settime",
1962 "settimeofday",
1963 "stime",
1964 "adjtimex",
1965 "set_mempolicy",
1966 "get_mempolicy",
1967 "mbind",
1968 "migrate_pages",
1969 "move_pages",
1970 "fanotify_init",
1971 "fanotify_mark",
1972 // 3. denied instead of argument-conditioned
1973 "seccomp",
1974 "personality",
1975 "kcmp",
1976];
1977
1978/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
1979/// project treats the filter as having outgrown its headroom. Held against the *measured* export
1980/// of the real filter by a test, not against an estimate: growing
1981/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
1982/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
1983///
1984/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
1985/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
1986/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
1987/// number was chosen against.
1988#[allow(dead_code)]
1989pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
1990
1991/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
1992/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
1993/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
1994/// two filters that straddle it in forked children.
1995#[allow(dead_code)]
1996pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
1997
1998/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
1999/// attached to one task may total, counting every filter it already carries plus a small
2000/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
2001/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
2002/// quoted: a test stacks filters in a forked child until the refusal arrives.
2003#[allow(dead_code)]
2004pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2005
2006/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
2007#[derive(Debug, Clone)]
2008pub(crate) struct ShellEnforcement {
2009 pub(crate) tier: EnforcementTier,
2010 /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
2011 /// by [`resolve_network_allowlist_ips`].
2012 ///
2013 /// The narrower of the egress proxy's two checks — the one applied to a destination the
2014 /// capsule reached without ever resolving a name for it (a hardcoded literal). See
2015 /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
2016 /// [`network_ip_allowed`].
2017 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2018 pub(crate) network_allow_ips: Vec<IpAddr>,
2019 /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
2020 /// checks a name when it resolves it and again when the connection to the address that name
2021 /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
2022 /// many tenants) in either direction.
2023 ///
2024 /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
2025 /// entry cannot mean one thing to a WASM guest and another to a subprocess.
2026 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2027 pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2028 /// The TCP ports the capsule's network namespace opens listeners on, derived from the
2029 /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
2030 /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
2031 /// consulted — the strongest form the refusal can take.
2032 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2033 pub(crate) egress_tcp_ports: Vec<u16>,
2034 /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
2035 /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
2036 /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
2037 /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
2038 /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
2039 /// platform for parity but never read off Linux.
2040 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2041 pub(crate) unix_sockets_allowed: bool,
2042 /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
2043 /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
2044 /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
2045 /// writes into its workdir can run, under any name. Consumed only by
2046 /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
2047 /// for parity but never read off Linux.
2048 ///
2049 /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
2050 /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
2051 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2052 pub(crate) workdir_exec: bool,
2053 /// The Landlock ABI the child's ruleset is built at: [`ruleset_landlock_abi`] of the session's
2054 /// [`HostProbe`] and [`Self::tier`], decided here so the ABI `session_start` records is the one
2055 /// installed. `None` on the tiers with no Landlock domain, and off Linux always.
2056 ///
2057 /// On `KernelFull`/`KernelSealed` a `None` builds at ABI v1, the narrowest set, so a missing
2058 /// reading can only narrow what the capsule may do. `Some(1)` likewise reproduces exactly
2059 /// what a 5.13–5.18 kernel installs, which is how the tests force that ruleset on a newer one.
2060 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2061 pub(crate) landlock_abi: Option<u8>,
2062 /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
2063 /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
2064 /// stdlib. Five origins, combined here:
2065 ///
2066 /// - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
2067 /// shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
2068 /// (they are individual files, where `ReadDir` was always a no-op);
2069 /// - one grant per `capabilities.shell.interpreter_runtime` directory, from
2070 /// `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
2071 /// declared;
2072 /// - one listable grant per `capabilities.shell.staged_runtime` tree, from
2073 /// `resolve_staged_runtime_landlock_grants`;
2074 /// - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
2075 /// [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
2076 /// `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
2077 /// tree, which nothing else grants;
2078 /// - on `KernelSealed` **only**, one non-executable read grant per fixed
2079 /// [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
2080 /// (`list_dir` per entry, since that list mixes directories and files) — the composed
2081 /// root's `/etc` allowlist, likewise granted by nothing else.
2082 ///
2083 /// The first three carry `executable: true`; the two tier-issued ones do not, and that
2084 /// asymmetry is load-bearing — see [`LandlockGrant`].
2085 ///
2086 /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
2087 /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
2088 /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
2089 /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
2090 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2091 pub(crate) landlock_grants: Vec<LandlockGrant>,
2092 /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
2093 /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
2094 /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
2095 ///
2096 /// Whole directories, never files: a bind carries a directory's whole contents, so this must
2097 /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
2098 /// every platform for parity, exactly like `landlock_grants` above.
2099 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2100 pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2101 /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
2102 /// read-only into the composed root at its own absolute path.
2103 ///
2104 /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
2105 /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
2106 /// while a staged tree that cannot be mounted aborts the composed-root construction before
2107 /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
2108 /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
2109 /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
2110 /// parity, exactly like the two fields above.
2111 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2112 pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2113 /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
2114 /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
2115 /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
2116 /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
2117 /// half. See [`crate::resources`].
2118 pub(crate) resource_limits: crate::resources::HostResourceLimits,
2119 /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
2120 /// for capsules that can actually spawn a native subprocess). `None` on macOS always, and on
2121 /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
2122 /// spawn one and could not be given a scope never reaches here, because the launch is
2123 /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
2124 ///
2125 /// Its presence decides each spawn's `RLIMIT_NPROC` ([`Self::nproc_bound`]): a spawn that
2126 /// joins it is bounded by `pids.max` alone. A native tool can still be dispatched on a
2127 /// session whose policy required no scope, so a spawn path never assumes one is here.
2128 pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2129 /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
2130 /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
2131 /// only at the next agent turn.
2132 pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2133 /// The session's one network namespace, which every command on a kernel tier joins.
2134 ///
2135 /// Shared by every clone of this enforcement, so a listener one command starts is reachable
2136 /// from the next. A fresh, not-yet-created namespace from [`Self::resolve`] and
2137 /// [`Self::environment_only`]; a plan inside a session takes its session's through
2138 /// [`Self::with_session_netns`]. Released by [`Self::end_session`].
2139 pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2140 /// The session's runtime directory and its `sealed-etc/` sibling: `$MURMUR_RUNTIME_DIR` in
2141 /// every command's environment, a read-only Landlock rule on the Landlock tiers, a read-only
2142 /// bind on `KernelSealed`, and the source of the composed root's `/etc/passwd` and
2143 /// `/etc/group`. `None` outside a session, where a command is told of no runtime directory
2144 /// and a sealed root cannot be composed.
2145 pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2146 /// The session's runtime directory itself, removed by [`Self::remove_runtime_dir`] on the
2147 /// process exits that run no destructor. Set only on the session's own enforcement.
2148 pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2149}
2150
2151impl ShellEnforcement {
2152 /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
2153 ///
2154 /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
2155 /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
2156 /// caller creates and attaches with [`Self::with_host_bounding`].
2157 ///
2158 /// `declared` is the already-combined containment floor for the session. It is what decides
2159 /// whether a sealed-capable host actually installs a composed root — see [`applied_tier`].
2160 ///
2161 /// `probe` is the session's own [`HostProbe`], taken by `runtime::stage_session` and carried
2162 /// on the `StagedSession`. Passed in rather than taken here so the mechanisms this installs
2163 /// are the ones the session's trace already claims.
2164 pub(crate) fn resolve(
2165 policy: &CapabilityPolicy,
2166 declared: murmur_artifact::ContainmentClass,
2167 probe: HostProbe,
2168 ) -> Result<Self, String> {
2169 let tier = applied_tier(probe.tier(), declared);
2170 let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2171 let network_allow_rules =
2172 crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2173 .map_err(|error| error.to_string())?;
2174 let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2175 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
2176 // The `DT_NEEDED`-closure files (individual files → non-listable) plus one grant
2177 // per author-declared `interpreter_runtime` directory (each with its own `list_dir`). A
2178 // directory not named in the manifest never receives a rule, regardless of what it holds.
2179 let mut landlock_grants =
2180 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
2181 landlock_grants.extend(resolve_interpreter_runtime_grants(
2182 &policy.shell_interpreter_runtime,
2183 ));
2184 // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
2185 // composed root and then denied by the Landlock ruleset installed inside it — see
2186 // `resolve_staged_runtime_landlock_grants`.
2187 landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2188 // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
2189 // directory — the composed root binds them but Landlock inside the root still denies
2190 // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
2191 // `scoped` capsule runs on) the same grant would expose real host directory shape. The
2192 // resolver applies that gate itself and returns nothing on every other tier.
2193 landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2194 // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
2195 // mounted-but-denied bug one directory over, and the reason TLS certificate verification
2196 // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
2197 // `KernelFull` these rules would apply to the *host's* /etc.
2198 landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2199 let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2200 let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2201 Ok(Self {
2202 tier,
2203 network_allow_ips,
2204 network_allow_rules,
2205 egress_tcp_ports,
2206 unix_sockets_allowed: policy.unix_sockets_allowed,
2207 workdir_exec: policy.workdir_exec_allowed,
2208 landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2209 landlock_grants,
2210 sealed_bind_dirs,
2211 staged_runtime_dirs,
2212 resource_limits: policy.resources,
2213 cgroup_scope: None,
2214 workdir_guard: None,
2215 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2216 runtime_dir: None,
2217 runtime_dir_owner: None,
2218 })
2219 }
2220
2221 /// Attach the session's runtime directory, and with `owner` the directory itself, so a
2222 /// process exit that runs no destructor removes it ([`Self::remove_runtime_dir`]).
2223 pub(crate) fn with_runtime_dir(
2224 mut self,
2225 roots: crate::runtime_dir::RuntimeDirRoots,
2226 owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2227 ) -> Self {
2228 self.runtime_dir = Some(roots);
2229 self.runtime_dir_owner = owner;
2230 self
2231 }
2232
2233 /// The runtime directory's absolute path, the value of `$MURMUR_RUNTIME_DIR`.
2234 pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2235 self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2236 }
2237
2238 /// The records directory's absolute path, the value of `$MURMUR_RECORDS_DIR`. `None` for an
2239 /// enforcement held outside a session.
2240 pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2241 self.runtime_dir
2242 .as_ref()
2243 .and_then(|roots| roots.records.as_ref())
2244 .map(crate::workdir_fs::WorkdirRoot::path)
2245 }
2246
2247 /// Removes the session's runtime directory now, ahead of a process exit that runs no
2248 /// destructor. Does nothing on an enforcement that does not own one.
2249 pub(crate) fn remove_runtime_dir(&self) {
2250 if let Some(owner) = &self.runtime_dir_owner {
2251 owner.remove();
2252 }
2253 }
2254
2255 /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
2256 /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
2257 ///
2258 /// Separate from [`Self::resolve`] because both are *live* per-session resources with
2259 /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
2260 /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
2261 /// creation so the fail-closed launch refusal can happen with a typed error before this is
2262 /// ever reached.
2263 pub(crate) fn with_host_bounding(
2264 mut self,
2265 cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2266 workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2267 ) -> Self {
2268 self.cgroup_scope = cgroup_scope;
2269 self.workdir_guard = workdir_guard;
2270 self
2271 }
2272
2273 /// The `RLIMIT_NPROC` decision for one spawn, made in the parent before its `pre_exec`
2274 /// closure is installed.
2275 ///
2276 /// [`crate::resources::NprocBound::CgroupPidsMax`] when the spawn will join a scope that
2277 /// places it, because both closures that receive this run the join fatally before `execve`.
2278 /// Otherwise [`crate::resources::NprocBound::Headroom`], with a baseline measured by
2279 /// `crate::resources::uid_task_count` on this call — a `/proc` walk on Linux — so call it
2280 /// once per spawn and never from inside `pre_exec`.
2281 pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2282 if self
2283 .cgroup_scope
2284 .as_ref()
2285 .is_some_and(|scope| scope.places_subprocesses())
2286 {
2287 crate::resources::NprocBound::CgroupPidsMax
2288 } else {
2289 crate::resources::NprocBound::Headroom {
2290 baseline: crate::resources::uid_task_count().unwrap_or(0),
2291 }
2292 }
2293 }
2294
2295 /// The latched workdir-size breach, if the guard has seen one.
2296 pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2297 self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2298 }
2299
2300 /// Refuse to spawn once the workdir ceiling has been crossed.
2301 ///
2302 /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
2303 /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
2304 /// the periodic check notices the breach, and the very next subprocess never starts.
2305 pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2306 match self.workdir_breach() {
2307 Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2308 None => Ok(()),
2309 }
2310 }
2311
2312 /// The permanent macOS/non-Linux value, and also what tests should construct directly to
2313 /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
2314 /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
2315 /// test code calls this directly today — production always goes through `resolve()`,
2316 /// which independently arrives at the same tier value on non-Linux hosts.)
2317 #[cfg_attr(not(test), allow(dead_code))]
2318 pub(crate) fn environment_only() -> Self {
2319 Self {
2320 tier: EnforcementTier::EnvironmentOnly,
2321 network_allow_ips: Vec::new(),
2322 network_allow_rules: Vec::new(),
2323 egress_tcp_ports: Vec::new(),
2324 // Denied, matching every other grant this constructor zeroes out. Inert on this tier
2325 // (no seccomp filter is installed at all), but it must not read as "allowed".
2326 unix_sockets_allowed: false,
2327 // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
2328 // Landlock domain exists to withhold anything from), but it must not read as "allowed".
2329 workdir_exec: false,
2330 // No Landlock domain on this tier. A test that spreads this over a Landlock tier
2331 // builds at ABI v1, the narrowest set.
2332 landlock_abi: None,
2333 landlock_grants: Vec::new(),
2334 sealed_bind_dirs: Vec::new(),
2335 staged_runtime_dirs: Vec::new(),
2336 // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
2337 // zeroing them out here would misrepresent what a real macOS host does.
2338 resource_limits: crate::resources::HostResourceLimits::default(),
2339 cgroup_scope: None,
2340 workdir_guard: None,
2341 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2342 runtime_dir: None,
2343 runtime_dir_owner: None,
2344 }
2345 }
2346
2347 /// Run this enforcement's commands in `session_netns` rather than in a namespace of its own.
2348 ///
2349 /// For a plan submitted inside a session: its shell steps are that session's commands, so
2350 /// they share its loopback and its egress proxy.
2351 pub(crate) fn with_session_netns(
2352 mut self,
2353 session_netns: Arc<crate::network_namespace::SessionNetns>,
2354 ) -> Self {
2355 self.session_netns = session_netns;
2356 self
2357 }
2358
2359 /// This enforcement for one native tool spawn: a clone whose exec allowlist also holds what
2360 /// the kernel itself executes to start `entrypoint`.
2361 ///
2362 /// That is the entrypoint, pinned to the inode the runtime installed, and the closure
2363 /// [`NativeEntrypoint`] derived from the installed image: the absolute interpreter on its `#!`
2364 /// line, and the `PT_INTERP`/`DT_NEEDED` closure — the native-tool counterpart of what
2365 /// `capabilities.shell.allow` derives for a shell binary. The entrypoint needs its own
2366 /// `Execute` rule because it sits in the runtime directory, whose rule grants reading and
2367 /// nothing else. On `KernelSealed` the interpreter's directory is also bound into the composed
2368 /// root when no fixed runtime path carries it; the entrypoint needs no bind, because the root
2369 /// binds the runtime directory at its own path.
2370 ///
2371 /// Nothing else is added, and nothing is read from the workdir here. Whatever the tool reads
2372 /// or runs once started is granted by the capsule, exactly as for a shell command. Every clone
2373 /// shares `session_netns`, so the tool joins the session's one namespace and egress proxy.
2374 pub(crate) fn for_native_entrypoint(&self, entrypoint: &NativeEntrypoint) -> ShellEnforcement {
2375 let mut enforcement = self.clone();
2376 if let Some(pinned) = entrypoint.pinned {
2377 enforcement.landlock_grants.push(LandlockGrant {
2378 path: entrypoint.path.clone(),
2379 list_dir: false,
2380 executable: true,
2381 pinned: Some(pinned),
2382 });
2383 }
2384 enforcement
2385 .landlock_grants
2386 .extend(LandlockGrant::non_listable_files(
2387 entrypoint.closure.clone(),
2388 ));
2389 if self.tier == EnforcementTier::KernelSealed {
2390 if let Some(dir) = entrypoint.interpreter.as_deref().and_then(Path::parent) {
2391 push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
2392 }
2393 }
2394 enforcement
2395 }
2396
2397 /// End the session: kill every process it started, then release its network namespace.
2398 ///
2399 /// Killing goes through the cgroup scope, which every spawn joins before `execve` and no fork
2400 /// escapes, so a server backgrounded with `&`, a `setsid` double fork and a demoted command
2401 /// still running are all in it. The namespace is released only once the scope is empty (or
2402 /// [`SESSION_END_KILL_WAIT`] has passed), so its proxy never stops under a process still
2403 /// using it. Idempotent; every spawn on this enforcement afterwards is refused.
2404 pub(crate) fn end_session(&self) {
2405 self.kill_session_processes();
2406 self.session_netns.end();
2407 }
2408
2409 /// Kill every process in this enforcement's cgroup scope and wait, bounded by
2410 /// [`SESSION_END_KILL_WAIT`], for the scope to empty. Does nothing without a scope.
2411 ///
2412 /// The half of [`Self::end_session`] a plan runs at its own end: a plan's scope is its own,
2413 /// and the namespace may be its session's.
2414 pub(crate) fn kill_session_processes(&self) {
2415 let Some(scope) = self.cgroup_scope.as_ref() else {
2416 return;
2417 };
2418 if let Err(error) = scope.kill_all() {
2419 crate::runtime_err!(
2420 "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
2421 {error}",
2422 scope.path().display()
2423 );
2424 }
2425 if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
2426 crate::runtime_err!(
2427 "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
2428 they were killed",
2429 scope.path().display(),
2430 SESSION_END_KILL_WAIT.as_millis()
2431 );
2432 }
2433 }
2434}
2435
2436/// How long [`ShellEnforcement::end_session`] waits for a killed scope's `cgroup.procs` to empty.
2437pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
2438
2439/// Calls [`ShellEnforcement::end_session`] when dropped.
2440///
2441/// Held by a launch for its whole length, so a session that ends by an error return still kills
2442/// what it started. Every path that ends a session normally calls `end_session` itself first, at
2443/// the point its teardown needs it; the call this makes then finds nothing left to do.
2444pub(crate) struct EndSessionOnDrop(ShellEnforcement);
2445
2446impl EndSessionOnDrop {
2447 pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
2448 Self(enforcement)
2449 }
2450}
2451
2452impl Drop for EndSessionOnDrop {
2453 fn drop(&mut self) {
2454 self.0.end_session();
2455 }
2456}
2457
2458/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
2459/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
2460/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
2461/// one or two concise sentences.
2462///
2463/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
2464/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
2465/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
2466/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
2467/// dynamically link, and the only writable path outside the workdir is `/dev/null`, one of the
2468/// three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp additionally refuses the
2469/// `socket(2)` domains in `denied_socket_domains` — closing the `/var/run/docker.sock` path, which
2470/// no Landlock ABI mediates for pathname sockets. The forked shell child also
2471/// drops every Linux capability before `execve` (see `linux_enforce::drop_all_capabilities`).
2472/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
2473/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
2474const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
2475resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
2476derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
2477shared libraries — nothing writable, no directory granted wholesale), plus a fixed device set \
2478every capsule gets unconditionally: /dev/null read+write (the only writable path outside the \
2479workdir), \
2480/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
2481raw block devices stay denied. The workdir's own grant also \
2482withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
2483capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
2484capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
2485defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
2486profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
2487and the forked shell child \
2488drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
2489denied, not absent: stat, access and readlink on an ungranted path still succeed.";
2490
2491/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
2492/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
2493/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
2494const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
2495host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
2496namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir \
2497the only writable path, a private /dev tmpfs carrying the OCI default device set), with Landlock \
2498and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
2499rather than denied — with one documented exception: /proc is a bind of the host's, not a masked \
2500private procfs, because mounting one unprivileged needs a PID namespace this tier does not create, \
2501so host process metadata stays visible there exactly as it is under scoped.";
2502
2503const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
2504lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
2505kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
2506verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
2507read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
2508mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
2509exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
2510capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
2511rule needs no Landlock and so applies identically on this tier, but it has not been verified \
2512either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
2513profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
2514tier too. The forked shell child still drops every Linux capability and \
2515sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
2516verified either. Treat shell subprocess isolation as experimental on this host.";
2517
2518const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
2519platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
2520enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
2521permanent on this platform.";
2522
2523/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
2524///
2525/// - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
2526/// is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
2527/// - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
2528/// (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
2529/// - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
2530/// sandboxing primitive at all on this platform, and this is permanent. Also still calls
2531/// `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
2532/// nothing is kernel-enforced.
2533///
2534/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
2535/// assert what each tier warns without capturing stderr.
2536pub(crate) fn tier_warning(
2537 tier: EnforcementTier,
2538 shell_allow_is_empty: bool,
2539) -> Option<(&'static str, &'static str)> {
2540 if shell_allow_is_empty {
2541 return None;
2542 }
2543 match tier {
2544 EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
2545 EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
2546 EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
2547 EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
2548 }
2549}
2550
2551const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn native subprocesses, but \
2552this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
2553tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
2554capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
2555does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
2556This is permanent on this platform, not a placeholder for a future slice.";
2557
2558/// Pure decision for the aggregate-bounding warning, split out of
2559/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
2560/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
2561///
2562/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
2563/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
2564/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
2565/// there is no running session left to warn about.
2566pub(crate) fn aggregate_bounding_warning(
2567 is_linux: bool,
2568 requires_bounding: bool,
2569 has_scope: bool,
2570) -> Option<(&'static str, &'static str)> {
2571 if is_linux || !requires_bounding || has_scope {
2572 return None;
2573 }
2574 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
2575}
2576
2577/// Fires at every launch, not just once.
2578pub(crate) fn warn_for_missing_aggregate_bounding(
2579 workdir: &crate::workdir_fs::WorkdirRoot,
2580 requires_bounding: bool,
2581 has_scope: bool,
2582) {
2583 let is_linux = cfg!(target_os = "linux");
2584 if let Some((code, message)) =
2585 aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
2586 {
2587 let link = security_warning_link(code);
2588 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2589 crate::agent::append_bootstrap_log(
2590 workdir,
2591 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2592 );
2593 }
2594}
2595
2596/// Fires at every launch, not just once.
2597pub(crate) fn warn_for_enforcement_tier(
2598 tier: EnforcementTier,
2599 workdir: &crate::workdir_fs::WorkdirRoot,
2600 policy: &CapabilityPolicy,
2601) {
2602 if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
2603 let link = security_warning_link(code);
2604 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2605 crate::agent::append_bootstrap_log(
2606 workdir,
2607 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2608 );
2609 }
2610 if tier == EnforcementTier::EnvironmentOnly {
2611 crate::runtime::warn_if_bash_network_bypass(workdir, policy);
2612 }
2613}
2614
2615/// What `prepare_enforcement` hands back alongside the configured `Command`: on a kernel tier, the
2616/// read end of the child's diagnostic pipe.
2617///
2618/// The session's egress proxy is not here. It belongs to the session's
2619/// [`crate::network_namespace::SessionNetns`] and outlives every command, so a process a command
2620/// backgrounds keeps its allowlisted egress after the command returns.
2621#[derive(Debug)]
2622pub(crate) enum SupervisorHandle {
2623 Noop,
2624 #[cfg(target_os = "linux")]
2625 Linux {
2626 /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
2627 /// child's `pre_exec` closure. On a successful `execve` the write end closes
2628 /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
2629 /// setup failure the child writes the real error message here before returning `Err`,
2630 /// letting `execute_shell` fold that legible detail into the error it returns instead of
2631 /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
2632 diag_read: std::os::fd::OwnedFd,
2633 },
2634}
2635
2636impl SupervisorHandle {
2637 /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
2638 /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
2639 /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
2640 /// there is zero cost there.
2641 ///
2642 /// The caller must have already released every *parent-side* copy of the pipe's write end
2643 /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
2644 /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
2645 /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
2646 /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
2647 /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
2648 /// failing).
2649 pub(crate) fn read_diagnostic(&self) -> Option<String> {
2650 match self {
2651 SupervisorHandle::Noop => None,
2652 #[cfg(target_os = "linux")]
2653 SupervisorHandle::Linux { diag_read, .. } => {
2654 use std::os::fd::AsRawFd;
2655 linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
2656 }
2657 }
2658 }
2659}
2660
2661// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
2662// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
2663// test builds; production `prepare_enforcement` has no bypass or injection point.
2664#[cfg(test)]
2665thread_local! {
2666 pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
2667 const { std::cell::Cell::new(false) };
2668}
2669
2670#[cfg(test)]
2671fn forced_prepare_failure() -> Result<(), String> {
2672 if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
2673 return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
2674 }
2675 Ok(())
2676}
2677
2678// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
2679// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
2680// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
2681// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
2682// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
2683// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
2684// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
2685// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
2686#[cfg(all(test, target_os = "linux"))]
2687thread_local! {
2688 pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
2689 const { std::cell::Cell::new(false) };
2690 pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
2691 const { std::cell::Cell::new(false) };
2692 /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
2693 /// only fail on a host that can attempt it at all, and the thing under test — that a
2694 /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
2695 /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
2696 /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
2697 pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
2698 const { std::cell::Cell::new(false) };
2699}
2700
2701/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
2702/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
2703/// and `runtime`'s (which checks what the dispatch layer does with it).
2704#[cfg(all(test, target_os = "linux"))]
2705pub(crate) struct ForceSealedRootFailureGuard;
2706
2707#[cfg(all(test, target_os = "linux"))]
2708impl ForceSealedRootFailureGuard {
2709 pub(crate) fn new() -> Self {
2710 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
2711 Self
2712 }
2713}
2714
2715#[cfg(all(test, target_os = "linux"))]
2716impl Drop for ForceSealedRootFailureGuard {
2717 fn drop(&mut self) {
2718 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
2719 }
2720}
2721
2722/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
2723/// half of the sealed mechanism still runs for real against it (the composed root is planned and
2724/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
2725/// which is what makes these tests run identically on a sealed-capable host and on one that could
2726/// never reach `KernelSealed` at all.
2727#[cfg(all(test, target_os = "linux"))]
2728pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
2729 // Kept past the test: the enforcement may be cloned into a thread that outlives the value.
2730 let base = tempfile::tempdir().unwrap().keep();
2731 ShellEnforcement {
2732 tier: EnforcementTier::KernelSealed,
2733 ..ShellEnforcement::environment_only()
2734 }
2735 .with_runtime_dir(test_runtime_dir(&base), None)
2736}
2737
2738/// `runtime/`, `sealed-etc/`, `harness/` and a records directory created under `base`, held as
2739/// a session's runtime directory roots.
2740#[cfg(test)]
2741pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
2742 let open = |sub: &str| {
2743 let path = base.join(sub);
2744 std::fs::create_dir_all(&path).unwrap();
2745 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
2746 };
2747 crate::runtime_dir::RuntimeDirRoots::new(
2748 open(crate::runtime_dir::RUNTIME_SUBDIR),
2749 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
2750 open(crate::runtime_dir::HARNESS_SUBDIR),
2751 )
2752 .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
2753}
2754
2755/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
2756/// exists — to `command`, so they apply to the spawned process and everything it forks.
2757///
2758/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
2759/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
2760/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. So this is
2761/// the whole of `prepare_enforcement` on `EnvironmentOnly` and off Linux, through which every
2762/// shell command, plan shell step and native tool is spawned.
2763///
2764/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
2765/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
2766/// allocation, no locking, no path resolution.
2767#[cfg(unix)]
2768pub(crate) fn attach_process_limits(
2769 command: &mut std::process::Command,
2770 enforcement: &ShellEnforcement,
2771) {
2772 use std::os::unix::process::CommandExt;
2773
2774 let limits = enforcement.resource_limits;
2775 let nproc = enforcement.nproc_bound();
2776 let cgroup_scope = enforcement.cgroup_scope.clone();
2777
2778 // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
2779 // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
2780 // scope exists, a single `write` to a descriptor opened by the parent before the fork —
2781 // no allocation, no locks, no path lookups.
2782 #[allow(unsafe_code)]
2783 unsafe {
2784 command.pre_exec(move || {
2785 crate::resources::apply_hard_rlimits(&limits, nproc)?;
2786 if let Some(scope) = cgroup_scope.as_ref() {
2787 scope.join_current_process()?;
2788 }
2789 Ok(())
2790 });
2791 }
2792}
2793
2794/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
2795/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
2796///
2797/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles a
2798/// spawn installs *before* `pre_exec` runs exist so the exec'd program inherits them across its
2799/// own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
2800/// stdout/stderr, and `dispatch_native_tool` additionally writes its `ToolInput` JSON to the
2801/// subprocess's stdin and parses its `ToolResult` JSON back off stdout. Marking 0/1/2
2802/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
2803/// invocation, and there is nothing meaningful to reopen them onto: the pipes themselves are the
2804/// intended destination. The gap this constant closes is everything *above*
2805/// stdio, where inheritance was never intended and was only ever prevented by the empirical
2806/// accident of nothing else happening to be open at spawn time.
2807///
2808/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
2809/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
2810/// module carry.
2811#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2812pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
2813
2814/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
2815/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
2816/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
2817/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
2818/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
2819/// no kernel sandbox at all.
2820#[cfg(not(target_os = "linux"))]
2821pub(crate) fn prepare_enforcement(
2822 command: &mut std::process::Command,
2823 enforcement: &ShellEnforcement,
2824 _accessible_workdir: &Path,
2825 _session_workdir: &crate::workdir_fs::WorkdirRoot,
2826) -> Result<SupervisorHandle, String> {
2827 #[cfg(test)]
2828 forced_prepare_failure()?;
2829
2830 debug_assert_eq!(
2831 enforcement.tier,
2832 EnforcementTier::EnvironmentOnly,
2833 "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly"
2834 );
2835 attach_process_limits(command, enforcement);
2836 Ok(SupervisorHandle::Noop)
2837}
2838
2839/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
2840/// submodule for the mechanics: the session's network namespace is established in the parent,
2841/// and the `pre_exec` closure joins it, then installs the composed root, seccomp and Landlock.
2842#[cfg(target_os = "linux")]
2843#[allow(unsafe_code)]
2844pub(crate) fn prepare_enforcement(
2845 command: &mut std::process::Command,
2846 enforcement: &ShellEnforcement,
2847 accessible_workdir: &Path,
2848 session_workdir: &crate::workdir_fs::WorkdirRoot,
2849) -> Result<SupervisorHandle, String> {
2850 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
2851 use std::os::unix::process::CommandExt;
2852
2853 #[cfg(test)]
2854 forced_prepare_failure()?;
2855
2856 if enforcement.tier == EnforcementTier::EnvironmentOnly {
2857 // No kernel sandbox to install, but the POSIX rlimit ceilings still apply — this tier is
2858 // only reachable on Linux from tests, and even there it must not read as "unbounded".
2859 attach_process_limits(command, enforcement);
2860 return Ok(SupervisorHandle::Noop);
2861 }
2862
2863 // The two descriptors a rule and a bind share on `KernelSealed`: the workdir-backed `/tmp`
2864 // store and the runtime directory, each taken once, with no link followed, from a held
2865 // descriptor. The Landlock rule is taken on the descriptor, and the bind is refused unless
2866 // the directory the child reaches is the one it names, so a link swapped in afterwards
2867 // changes neither.
2868 let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
2869 Some(SealedSourceFds::open(session_workdir, enforcement)?)
2870 } else {
2871 None
2872 };
2873 // On `KernelFull` the runtime directory needs the Landlock rule alone.
2874 let runtime_dir_fd = match &sealed_sources {
2875 Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
2876 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
2877 .runtime_dir
2878 .as_ref()
2879 .map(|roots| roots.runtime.path_fd().map(Arc::new))
2880 .transpose()
2881 .map_err(|error| {
2882 format!(
2883 "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
2884 )
2885 })?,
2886 None => None,
2887 };
2888 // The records directory takes the runtime directory's rule on both Landlock tiers, from the
2889 // same descriptor its sealed bind verifies.
2890 let records_dir_fd = match &sealed_sources {
2891 Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
2892 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
2893 .runtime_dir
2894 .as_ref()
2895 .and_then(|roots| roots.records.as_ref())
2896 .map(|records| records.path_fd().map(Arc::new))
2897 .transpose()
2898 .map_err(|error| {
2899 format!(
2900 "sandbox: failed to open the records directory for Landlock scoping: {error}"
2901 )
2902 })?,
2903 None => None,
2904 };
2905
2906 // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
2907 // is a normal synchronous error here — it names the path and returns before any subprocess is
2908 // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
2909 // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
2910 // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
2911 // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
2912 let landlock_fds = if matches!(
2913 enforcement.tier,
2914 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
2915 ) {
2916 Some(linux_enforce::open_landlock_fds(
2917 accessible_workdir,
2918 &enforcement.landlock_grants,
2919 &landlock_device_grants(enforcement.tier),
2920 enforcement.workdir_exec,
2921 enforcement.landlock_abi,
2922 sealed_sources.as_ref(),
2923 runtime_dir_fd,
2924 records_dir_fd,
2925 )?)
2926 } else {
2927 None
2928 };
2929
2930 // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
2931 // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
2932 // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
2933 // thread that no longer exists — happens now instead, synchronously, where a failure names
2934 // itself. See `crate::sealed` for the plan/execute split.
2935 let sealed_spec = match &sealed_sources {
2936 Some(sources) => Some(build_sealed_root(
2937 accessible_workdir,
2938 sources,
2939 &enforcement.sealed_bind_dirs,
2940 &enforcement.staged_runtime_dirs,
2941 )?),
2942 None => None,
2943 };
2944
2945 // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
2946 // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
2947 // nothing written (the parent's read then returns immediate EOF — zero cost on the success
2948 // path), while any `pre_exec` setup failure writes its real message here before the closure
2949 // returns `Err`.
2950 let mut diag_fds = [0i32; 2];
2951 // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
2952 // other precondition applies.
2953 let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
2954 if rc != 0 {
2955 return Err(format!(
2956 "sandbox: pipe2() for enforcement diagnostics failed: {}",
2957 std::io::Error::last_os_error()
2958 ));
2959 }
2960 // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
2961 // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
2962 let (diag_read, diag_write) = unsafe {
2963 (
2964 OwnedFd::from_raw_fd(diag_fds[0]),
2965 OwnedFd::from_raw_fd(diag_fds[1]),
2966 )
2967 };
2968
2969 // The session's network namespace, created by the first command that needs it and joined by
2970 // every one after. Established here, in the parent and before the fork, so the egress proxy
2971 // is already serving the namespace's listeners when the command's first connection arrives,
2972 // and so a namespace that cannot be created fails this spawn with a legible error instead of
2973 // running the command anywhere else. The join handle moves into the closure, which keeps both
2974 // descriptors open until the `Command` is dropped.
2975 let netns = enforcement.session_netns.establish(
2976 &enforcement.egress_tcp_ports,
2977 crate::egress_proxy::EgressPolicy::new(
2978 enforcement.network_allow_rules.clone(),
2979 enforcement.network_allow_ips.clone(),
2980 ),
2981 )?;
2982
2983 let tier = enforcement.tier;
2984 // Copied out before the `move` closure below takes ownership of everything it touches, the
2985 // same clone-before-move shape `tier` and the Landlock fds already use.
2986 let unix_sockets_allowed = enforcement.unix_sockets_allowed;
2987 // Same clone-before-move shape. `HostResourceLimits` and `NprocBound` are `Copy`, and the
2988 // `RLIMIT_NPROC` decision is made here because its baseline is a `/proc` walk; the cgroup scope
2989 // is an `Arc` whose `cgroup.procs` descriptor was opened in the parent at scope-creation time,
2990 // so the child performs no path lookup to join it.
2991 let resource_limits = enforcement.resource_limits;
2992 let nproc = enforcement.nproc_bound();
2993 let cgroup_scope = enforcement.cgroup_scope.clone();
2994
2995 // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
2996 // narrow pre_exec window where only async-signal-safe operations are permitted. It performs
2997 // two `setns` calls into the session's namespaces, the capability-dropping `prctl`/`capset`
2998 // sequence (bounding-set drop, capset clear, and the explicit `no_new_privs` `prctl`) plus one
2999 // allocation-free `open`/`read`/`close` of `/proc/sys/kernel/cap_last_cap`, libseccomp filter
3000 // construction/load (kernel syscalls), and (KernelFull only) Landlock ruleset
3001 // construction/`restrict_self` against already-open fds (also kernel syscalls) — no
3002 // `open()`/`canonicalize()` beyond that one `cap_last_cap` read, no locks beyond what those
3003 // syscalls need. On any failure it writes the real error message to the CLOEXEC diagnostic
3004 // pipe (best-effort, bounded, raw `write` loop) before returning `Err`. `diag_write`, the
3005 // namespace join handle and the Landlock fds are moved in, and are `CLOEXEC`, so none of them
3006 // reaches the exec'd program.
3007 unsafe {
3008 command.pre_exec(move || {
3009 // Ordered first, before any seccomp filter is installed: these are the bounds that
3010 // must hold even if a later step in this closure fails, and `prlimit64`/`setrlimit`
3011 // plus `write` are all already in `SECCOMP_SYSCALL_ALLOWLIST` either way.
3012 if let Err(error) = crate::resources::apply_hard_rlimits(&resource_limits, nproc) {
3013 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3014 return Err(error);
3015 }
3016 if let Some(scope) = cgroup_scope.as_ref() {
3017 if let Err(error) = scope.join_current_process() {
3018 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3019 return Err(error);
3020 }
3021 }
3022
3023 let (user_ns_fd, net_ns_fd) = netns.raw_fds();
3024 match linux_enforce::child_install_enforcement(
3025 tier,
3026 user_ns_fd,
3027 net_ns_fd,
3028 sealed_spec.as_ref(),
3029 landlock_fds.as_ref(),
3030 unix_sockets_allowed,
3031 ) {
3032 Ok(()) => Ok(()),
3033 Err(error) => {
3034 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3035 Err(error)
3036 }
3037 }
3038 });
3039 }
3040
3041 Ok(SupervisorHandle::Linux { diag_read })
3042}
3043
3044/// Which fixed device set Landlock grants, keyed on the tier.
3045///
3046/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
3047/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
3048/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
3049/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
3050/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
3051///
3052/// The parent opens these paths on the *host*, before the namespace exists; because the composed
3053/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
3054/// are the inodes the capsule reaches.
3055#[cfg(target_os = "linux")]
3056fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
3057 match tier {
3058 EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
3059 .iter()
3060 .map(|device| CapsuleDeviceGrant {
3061 path: device.path,
3062 writable: device.writable,
3063 })
3064 .collect(),
3065 _ => CAPSULE_DEVICE_GRANTS.to_vec(),
3066 }
3067}
3068
3069/// Creates (idempotently) the directory inside the session workdir that backs `/tmp` in the
3070/// composed root, and returns an `O_PATH` descriptor for it taken with no link followed.
3071///
3072/// The descriptor is the `/tmp` Landlock rule's fd, and its `(st_dev, st_ino)` is the identity
3073/// the child verifies its bind source against, so a link swapped onto `.mur-tmp` after it is
3074/// taken redirects neither, and a link already there is refused. See [`crate::sealed::SEALED_TMP_DIR_NAME`] for why `/tmp` is
3075/// workdir-backed rather than a second tmpfs.
3076#[cfg(target_os = "linux")]
3077fn open_sealed_tmp_store(
3078 session_workdir: &crate::workdir_fs::WorkdirRoot,
3079) -> Result<std::os::fd::OwnedFd, String> {
3080 use crate::sealed::SEALED_TMP_DIR_NAME;
3081 let failed = |error| format!("sealed: failed to open the workdir-backed /tmp store: {error}");
3082 session_workdir
3083 .create_dir_all(SEALED_TMP_DIR_NAME, crate::workdir_fs::DEFAULT_DIR_MODE)
3084 .map_err(failed)?;
3085 #[cfg(test)]
3086 if let Some(hook) = BEFORE_TMP_STORE_FD.with(|slot| slot.borrow_mut().take()) {
3087 hook();
3088 }
3089 session_workdir
3090 .path_fd_beneath(SEALED_TMP_DIR_NAME)
3091 .map_err(failed)
3092}
3093
3094#[cfg(all(test, target_os = "linux"))]
3095thread_local! {
3096 /// Runs between the `/tmp` store's creation and its descriptor, so a test can plant a link in
3097 /// the window a racing command would use.
3098 static BEFORE_TMP_STORE_FD: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
3099 const { std::cell::RefCell::new(None) };
3100}
3101
3102/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
3103/// of the host's into the session's `sealed-etc/` directory, returning their host paths in
3104/// [`crate::sealed::SEALED_ETC_PATHS`] order.
3105///
3106/// `sealed-etc/` sits beside the runtime directory, outside every workdir, and no capsule command
3107/// is granted it. The account the files name is the session workdir's owner, the identity the
3108/// capsule's subprocesses run as, with the session's synthetic home as its home directory.
3109///
3110/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
3111/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
3112/// nothing the parent could have done for it.
3113///
3114/// The rewrite is in place ([`crate::workdir_fs::WorkdirRoot::overwrite_in_place`] truncates; it
3115/// does not replace): Landlock grants the file by inode and the composed root binds it by path, so
3116/// replacing the file would leave `/etc/passwd` bound but denied (`EACCES`).
3117#[cfg(target_os = "linux")]
3118fn ensure_sealed_identity_files(
3119 sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
3120 session_workdir: &crate::workdir_fs::WorkdirRoot,
3121) -> Result<Vec<PathBuf>, String> {
3122 // The workdir was created by this process, so its owner is the identity the capsule's
3123 // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
3124 let (uid, gid) = session_workdir.owner().map_err(|error| {
3125 format!("sealed: failed to stat the session workdir for the capsule's uid/gid: {error}")
3126 })?;
3127 // The same string `build_shell_env` puts in `$HOME`, from the same constant, resolved against
3128 // the same directory: the synthetic home lives in the session workdir, so a `pw_dir` composed
3129 // against the accessible one would name a directory that does not exist.
3130 let home = session_workdir.host_path(crate::shell::SYNTHETIC_HOME_DIR_NAME);
3131 let home = home.to_string_lossy().into_owned();
3132 let identity = crate::sealed::SealedAccountIdentity {
3133 uid,
3134 gid,
3135 home: &home,
3136 };
3137
3138 let mut written = Vec::new();
3139 for entry in crate::sealed::SEALED_ETC_PATHS {
3140 let Some(file) = entry.synthetic else {
3141 continue;
3142 };
3143 sealed_etc_root
3144 .overwrite_in_place(
3145 file.file_name(),
3146 file.render(&identity)?.as_bytes(),
3147 crate::workdir_fs::DEFAULT_FILE_MODE,
3148 )
3149 .map_err(|error| {
3150 format!(
3151 "sealed: failed to write the synthetic {}: {error}",
3152 entry.path
3153 )
3154 })?;
3155 written.push(crate::sealed::synthetic_etc_source(
3156 sealed_etc_root.path(),
3157 file,
3158 ));
3159 }
3160 Ok(written)
3161}
3162
3163/// The descriptors a composed root's runtime-provided binds are verified against and its Landlock
3164/// rules are taken on, opened in the parent before the fork.
3165#[cfg(target_os = "linux")]
3166pub(crate) struct SealedSourceFds {
3167 /// The session workdir's `.mur-tmp`, from [`open_sealed_tmp_store`]: the `/tmp` rule's fd.
3168 tmp_store: Arc<std::os::fd::OwnedFd>,
3169 /// `.mur-tmp`'s host path, which the child opens and verifies against [`Self::tmp_store`].
3170 tmp_store_path: PathBuf,
3171 /// The runtime directory, reopened from its root's held descriptor: its rule's fd.
3172 runtime_dir: Arc<std::os::fd::OwnedFd>,
3173 /// The runtime directory's absolute path, which the bind reproduces inside the root and the
3174 /// child verifies against [`Self::runtime_dir`].
3175 runtime_dir_path: PathBuf,
3176 /// The records directory, reopened from its root's held descriptor, and its absolute path:
3177 /// its rule's fd and its read-only bind, on the runtime directory's terms. `None` for an
3178 /// enforcement held outside a session.
3179 records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3180 /// `sealed-etc/`'s host path.
3181 etc_dir: PathBuf,
3182 /// The synthetic `/etc` files, in [`crate::sealed::SEALED_ETC_PATHS`] order.
3183 identity_files: Vec<PathBuf>,
3184}
3185
3186#[cfg(target_os = "linux")]
3187impl SealedSourceFds {
3188 /// Creates the `/tmp` store and the synthetic `/etc` files and takes every descriptor.
3189 /// A sealed root cannot be composed for an enforcement that holds no runtime directory.
3190 fn open(
3191 session_workdir: &crate::workdir_fs::WorkdirRoot,
3192 enforcement: &ShellEnforcement,
3193 ) -> Result<Self, String> {
3194 let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
3195 "sealed: a composed root needs the session's runtime directory, and this \
3196 enforcement holds none"
3197 .to_string()
3198 })?;
3199 let tmp_store = Arc::new(open_sealed_tmp_store(session_workdir)?);
3200 let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, session_workdir)?;
3201 let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
3202 format!("sealed: failed to open the runtime directory for its bind: {error}")
3203 })?);
3204 let records_dir = roots
3205 .records
3206 .as_ref()
3207 .map(|records| {
3208 records
3209 .path_fd()
3210 .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
3211 .map_err(|error| {
3212 format!(
3213 "sealed: failed to open the records directory for its bind: {error}"
3214 )
3215 })
3216 })
3217 .transpose()?;
3218 Ok(Self {
3219 tmp_store,
3220 tmp_store_path: session_workdir.host_path(crate::sealed::SEALED_TMP_DIR_NAME),
3221 runtime_dir,
3222 runtime_dir_path: roots.runtime.path().to_path_buf(),
3223 records_dir,
3224 etc_dir: roots.sealed_etc.path().to_path_buf(),
3225 identity_files,
3226 })
3227 }
3228
3229 /// `(st_dev, st_ino)` of `fd`, which a bind's source must still have in the child.
3230 fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
3231 crate::workdir_fs::fd_identity(fd)
3232 .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
3233 }
3234}
3235
3236/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
3237/// host layout, and lower the plan into the C-string form the forked child executes.
3238///
3239/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
3240/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
3241/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
3242/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
3243/// declared-vs-achieved mismatch.
3244///
3245/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
3246/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
3247/// error would return `Err(String)`, which converts to the ordinary, retryable
3248/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
3249/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
3250/// classification for a capsule that did not get a runtime tree it declared.
3251#[cfg(target_os = "linux")]
3252fn build_sealed_root(
3253 accessible_workdir: &Path,
3254 sources: &SealedSourceFds,
3255 extra_read_only: &[PathBuf],
3256 staged_runtime_read_only: &[PathBuf],
3257) -> Result<crate::sealed::SealedRootSpec, String> {
3258 use crate::sealed;
3259
3260 // The workdir must be absolute for its path to mean the same thing inside the composed root,
3261 // and `launch_session` always creates it as one — but the composed root's whole design rests
3262 // on it, so it is checked rather than assumed.
3263 if !accessible_workdir.is_absolute() {
3264 return Err(format!(
3265 "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
3266 its own absolute path",
3267 accessible_workdir.display()
3268 ));
3269 }
3270
3271 let base = sealed::choose_root_base(
3272 accessible_workdir,
3273 sealed::SEALED_ROOT_BASE_CANDIDATES,
3274 |path| path.is_dir(),
3275 )
3276 .ok_or_else(|| {
3277 format!(
3278 "sealed: no usable base directory for the composed root; none of {:?} exists outside \
3279 the session workdir's own path",
3280 sealed::SEALED_ROOT_BASE_CANDIDATES
3281 )
3282 })?;
3283
3284 let plan = sealed::plan_composed_root(
3285 accessible_workdir,
3286 &sealed::SealedBindSources {
3287 tmp_store: (
3288 &sources.tmp_store_path,
3289 SealedSourceFds::identity_of(&sources.tmp_store)?,
3290 ),
3291 etc_dir: &sources.etc_dir,
3292 runtime_dir: Some((
3293 &sources.runtime_dir_path,
3294 SealedSourceFds::identity_of(&sources.runtime_dir)?,
3295 )),
3296 records_dir: sources
3297 .records_dir
3298 .as_ref()
3299 .map(|(fd, path)| {
3300 SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
3301 })
3302 .transpose()?,
3303 },
3304 &base,
3305 extra_read_only,
3306 staged_runtime_read_only,
3307 &sealed::RealHostLayout,
3308 );
3309 sealed::build_sealed_root_spec(&plan)
3310}
3311
3312/// Linux-only mechanics: the `pre_exec` enforcement sequence, seccomp filter construction and
3313/// Landlock application.
3314///
3315/// Nothing here uses `libseccomp`'s notify facility. `connect`/`sendto` are enforced by the
3316/// network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which decide
3317/// from kernel-held state. A `Notify` rule would reintroduce a `/proc/<pid>/mem` read on the hot
3318/// path, where the value read can change between the decision and the kernel's own dereference.
3319#[cfg_attr(target_os = "linux", allow(unsafe_code))]
3320#[cfg(target_os = "linux")]
3321mod linux_enforce {
3322 use std::io;
3323 use std::os::fd::{AsFd, OwnedFd, RawFd};
3324 use std::path::Path;
3325
3326 use landlock::{make_bitflags, AccessFs, BitFlags};
3327
3328 use super::{EnforcementTier, LandlockGrant};
3329
3330 /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
3331 /// naming the failed step is far shorter than this; the bound just keeps the best-effort
3332 /// `write`/`read` loops trivially terminating.
3333 const MAX_DIAG_LEN: usize = 1024;
3334
3335 /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
3336 /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
3337 /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
3338 /// never a path to re-open there.
3339 pub(super) struct OpenLandlockGrant {
3340 fd: OwnedFd,
3341 list_dir: bool,
3342 executable: bool,
3343 }
3344
3345 /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
3346 /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
3347 /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
3348 /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
3349 /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
3350 /// their bits must not be confusable.
3351 pub(super) struct OpenDeviceGrant {
3352 fd: OwnedFd,
3353 writable: bool,
3354 }
3355
3356 /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
3357 /// workdir's fd (scoped by [`workdir_access_rights`]), the workdir-backed `/tmp` store's fd on
3358 /// `KernelSealed`, each successfully-opened grant fd, and each successfully-opened fixed-device
3359 /// fd.
3360 /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
3361 /// against these fds without performing a single `open()`.
3362 pub(super) struct LandlockChildFds {
3363 workdir_fd: OwnedFd,
3364 /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
3365 /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
3366 /// but its argument — has the whole workdir rule decided for it before fork.
3367 workdir_exec: bool,
3368 /// The Landlock ABI the ruleset is built at, decided in the parent from the session's
3369 /// [`super::HostProbe`] (see [`super::ShellEnforcement::landlock_abi`]) so the child reads
3370 /// no host state of its own. `None` builds at ABI v1, the narrowest set.
3371 abi: Option<u8>,
3372 /// `<workdir>/.mur-tmp` — the directory `plan_composed_root` bind-mounts at `/tmp` inside
3373 /// the composed root — on `KernelSealed`, and `None` on every other tier, which has no
3374 /// composed root and therefore no such bind. The same descriptor is the bind's source.
3375 ///
3376 /// It needs an fd of its own even though it lives *inside* the workdir: Landlock resolves a
3377 /// path by walking dentries up to each mount root, so an access to `/tmp/x` inside the root
3378 /// is matched against the `.mur-tmp` inode (the bind's root) and then against the composed
3379 /// root's own `/tmp` mountpoint — never against the workdir inode. The workdir's rule
3380 /// therefore does not reach the bind, and without this fd every `/tmp` access is `EACCES`
3381 /// on a mount that is genuinely writable.
3382 sealed_tmp_fd: Option<std::sync::Arc<OwnedFd>>,
3383 /// The synthetic `/etc/passwd`/`/etc/group` in the session's `sealed-etc/`, opened on
3384 /// `KernelSealed` and empty on every other tier.
3385 ///
3386 /// Exactly the [`Self::sealed_tmp_fd`] problem, one directory over. The grant list already
3387 /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
3388 /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
3389 /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
3390 /// at the bound file's own inode, so without these fds both account databases would be
3391 /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
3392 ///
3393 /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
3394 /// nothing inside a capsule has any business editing an account database.
3395 sealed_identity_fds: Vec<OwnedFd>,
3396 /// The session's runtime directory, reopened from its root's held descriptor: `ReadFile`
3397 /// and `ReadDir` and nothing else, so every command reads `MURMUR.md`, `task.md`, plans
3398 /// and installed tools and changes none of them. `None` outside a session. On
3399 /// `KernelSealed` the same descriptor is the read-only bind's source.
3400 runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
3401 /// The session's records directory, reopened from its root's held descriptor, granted on
3402 /// [`Self::runtime_dir_fd`]'s terms: every command reads the trace and logs and changes
3403 /// none of them. `None` outside a session. On `KernelSealed` the same descriptor is the
3404 /// read-only bind's source.
3405 records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
3406 grants: Vec<OpenLandlockGrant>,
3407 devices: Vec<OpenDeviceGrant>,
3408 }
3409
3410 impl LandlockChildFds {
3411 /// The workdir-backed `/tmp` fd, for the tier-gate test. Keeps the struct's fields private
3412 /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
3413 #[cfg(test)]
3414 pub(super) fn sealed_tmp_fd(&self) -> Option<&OwnedFd> {
3415 self.sealed_tmp_fd.as_deref()
3416 }
3417
3418 /// The runtime directory's fd, for the tier-gate test, on the same terms.
3419 #[cfg(test)]
3420 pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
3421 self.runtime_dir_fd.as_deref()
3422 }
3423
3424 /// The records directory's fd, for the tier-gate test, on the same terms.
3425 #[cfg(test)]
3426 pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
3427 self.records_dir_fd.as_deref()
3428 }
3429
3430 /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
3431 /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
3432 #[cfg(test)]
3433 pub(super) fn sealed_identity_fd_count(&self) -> usize {
3434 self.sealed_identity_fds.len()
3435 }
3436
3437 /// How many grant fds were opened, for the pinned-grant test. A count, for the same reason.
3438 #[cfg(test)]
3439 pub(super) fn grant_count(&self) -> usize {
3440 self.grants.len()
3441 }
3442 }
3443
3444 /// The Landlock rights granted on the capsule **workdir's own** `PathBeneath` rule,
3445 /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
3446 /// declared `capabilities.filesystem.workdir_exec: true`.
3447 ///
3448 /// Spelled out variant by variant rather than `AccessFs::from_all` for two reasons: `from_all`
3449 /// hides what is actually handed to the capsule, and four rights are withheld from this
3450 /// constant. It is the set for [`super::LANDLOCK_TARGET_ABI`]; `apply_landlock_scope` masks it
3451 /// to the ABI the ruleset is built at, so on an older kernel the rights that ABI does not know
3452 /// simply drop out.
3453 ///
3454 /// Two rights are granted from an ABI later than v1:
3455 ///
3456 /// - `Refer` (ABI v2, Linux 5.19) — moving and hard-linking a file between two folders.
3457 /// Landlock refuses every cross-folder `rename(2)`/`link(2)` with `EXDEV` unless the ruleset
3458 /// handles `Refer` and both folders grant it, so without it every tool that renames a
3459 /// finished file into place from a sibling folder fails in the workdir. It reaches nothing
3460 /// outside: a move or link needs `Refer` on both the source and the destination folder, no
3461 /// rule outside the workdir and its own `/tmp` store carries it, and Landlock also refuses
3462 /// any move that would leave the file with more rights at its destination than its source.
3463 /// - `Truncate` (ABI v3, Linux 6.2) — `truncate(2)`, `ftruncate(2)` and `open(O_TRUNC)`.
3464 /// Below v3 Landlock does not mediate truncation at all, so a capsule could zero any host
3465 /// file its uid can write. Handling it denies that everywhere except the rules that grant
3466 /// it, which are exactly the ones already carrying `WriteFile`: this one and the writable
3467 /// devices.
3468 ///
3469 /// - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
3470 /// makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
3471 /// on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
3472 /// cannot run under any name — including an allowlisted basename, the exact bypass a prior
3473 /// release shipped. The cost: a binary the capsule legitimately compiled in its workdir
3474 /// cannot run either, which is what `workdir_exec: true` buys back.
3475 /// - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
3476 /// whole scope. A capsule running as root could `mknod` a node for the host's own disk
3477 /// (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
3478 /// permits, because the new inode lives beneath a granted path — then `open()` it and
3479 /// read the raw host filesystem underneath every other restriction. No known workload
3480 /// creates device nodes in its working tree.
3481 /// - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
3482 /// filesystem object with a kernel-side identity beyond a regular file), but unlike the
3483 /// two device rights this one is a genuine open question: some build tooling and some
3484 /// language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
3485 /// that breaks those is not a fix. The manual acceptance procedure in
3486 /// `docs/content/reference/workdir-device-node-manual-verification.md` carries an
3487 /// explicit unix-socket scenario for exactly this; if the team's
3488 /// real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
3489 ///
3490 /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
3491 /// and a FIFO carries none of the raw-device risk above.
3492 ///
3493 /// Because `apply_landlock_scope`'s `handle_access` declares the *full* `from_all` set of the
3494 /// ABI it builds at, a right this rule withholds is not merely "not extra-granted" — it
3495 /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
3496 /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
3497 /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
3498 /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
3499 /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
3500 /// nowhere the capsule writes.
3501 pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
3502 WriteFile
3503 | ReadFile
3504 | ReadDir
3505 | RemoveDir
3506 | RemoveFile
3507 | MakeDir
3508 | MakeReg
3509 | MakeFifo
3510 | MakeSym
3511 | Refer
3512 | Truncate
3513 });
3514
3515 /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
3516 /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
3517 ///
3518 /// A function rather than a second constant so the two cases cannot drift: adding a right to
3519 /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
3520 /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
3521 /// The whole point of the default is that the workdir grant is no longer name-based at all.
3522 pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
3523 if workdir_exec {
3524 WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
3525 } else {
3526 WORKDIR_ACCESS_RIGHTS_NO_EXEC
3527 }
3528 }
3529
3530 /// The kernel's Landlock ABI version, or `None` when the kernel has no Landlock.
3531 ///
3532 /// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the version and
3533 /// creates nothing — no ruleset fd and no domain — so unlike
3534 /// [`probe_landlock_full_access`] it is safe to call in-process. A kernel built without
3535 /// Landlock, or with it disabled at boot, fails it with `ENOSYS`/`EOPNOTSUPP`.
3536 pub(super) fn kernel_landlock_abi() -> Option<u8> {
3537 const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
3538 // SAFETY: with a null attribute pointer, a zero size and only the VERSION flag set, the
3539 // kernel reads no memory and allocates no fd; the call has no side effect to undo.
3540 let version = unsafe {
3541 libc::syscall(
3542 libc::SYS_landlock_create_ruleset,
3543 std::ptr::null::<libc::c_void>(),
3544 0usize,
3545 LANDLOCK_CREATE_RULESET_VERSION,
3546 )
3547 };
3548 if version <= 0 {
3549 return None;
3550 }
3551 Some(u8::try_from(version).unwrap_or(u8::MAX))
3552 }
3553
3554 /// Probes real Landlock support by building a ruleset granting full access to `/` and
3555 /// calling `restrict_self()` **in a forked child**, so the host process is never placed
3556 /// into a Landlock domain by a mere capability check.
3557 ///
3558 /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
3559 /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
3560 /// mount family to the task and every descendant it will ever have, which is how Landlock
3561 /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
3562 /// before the sealed probe, and a session's probe is taken before any fork that would
3563 /// build a composed root, so restricting in-process poisons `sealed` on every host and every
3564 /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
3565 /// to suggest the runtime did it to itself.
3566 ///
3567 /// The ruleset is built in the parent because creating one and adding rules restricts
3568 /// nothing; only `restrict_self` applies it. That keeps everything the child does after
3569 /// the fork down to a `prctl` and one syscall.
3570 pub(super) fn probe_landlock_full_access() -> Option<bool> {
3571 use landlock::{
3572 Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
3573 RulesetCreatedAttr, RulesetStatus, ABI,
3574 };
3575
3576 // The child reports which of the three outcomes it reached through its exit status;
3577 // nothing else crosses the fork.
3578 const PROBE_FULLY_ENFORCED: i32 = 0;
3579 const PROBE_PARTIAL: i32 = 1;
3580 const PROBE_FAILED: i32 = 2;
3581
3582 // ABI v1 on purpose: this decides whether a Landlock tier is available at all, which v1
3583 // answers on every Landlock kernel. The ABI a capsule's ruleset is built at is read
3584 // separately, by `kernel_landlock_abi`.
3585 let abi = ABI::V1;
3586 let access_all = AccessFs::from_all(abi);
3587
3588 let root_fd = PathFd::new("/").ok()?;
3589
3590 let ruleset = Ruleset::default()
3591 .set_compatibility(CompatLevel::BestEffort)
3592 .handle_access(access_all)
3593 .ok()?
3594 .create()
3595 .ok()?
3596 .add_rule(PathBeneath::new(root_fd, access_all))
3597 .ok()?;
3598
3599 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
3600 // confines itself to async-signal-safe work. Everything this child does is the
3601 // `restrict_self` syscall pair and `_exit`; the allocating part of building the
3602 // ruleset already happened above, in the parent.
3603 let pid = unsafe { libc::fork() };
3604 if pid < 0 {
3605 return None;
3606 }
3607 if pid == 0 {
3608 let code = match ruleset.restrict_self() {
3609 Ok(status) => {
3610 if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
3611 PROBE_FULLY_ENFORCED
3612 } else {
3613 PROBE_PARTIAL
3614 }
3615 }
3616 Err(_) => PROBE_FAILED,
3617 };
3618 // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
3619 // which is what keeps the parent's state untouched.
3620 unsafe { libc::_exit(code) }
3621 }
3622
3623 let mut wait_status: libc::c_int = 0;
3624 // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
3625 let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
3626 if waited < 0 || !libc::WIFEXITED(wait_status) {
3627 return None;
3628 }
3629 match libc::WEXITSTATUS(wait_status) {
3630 PROBE_FULLY_ENFORCED => Some(true),
3631 PROBE_PARTIAL => Some(false),
3632 // A `restrict_self` that errored tells us nothing either way, which is the same
3633 // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
3634 _ => None,
3635 }
3636 }
3637
3638 /// Runs inside the forked child, pre-exec: joins the session's network namespace through
3639 /// `user_ns_fd`/`net_ns_fd`, strips the child's Linux capabilities, installs the seccomp filter
3640 /// (the `socket()` domain denials keyed on `unix_sockets_allowed`, over a default-deny syscall
3641 /// allowlist), and (on `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope —
3642 /// which is also what enforces `capabilities.shell.allow`. Returning `Err` here aborts the
3643 /// exec (std's `Command` machinery propagates it back to the parent's `.spawn()` call as an
3644 /// `io::Error`) — the fail-closed path for setup failures that happen after fork.
3645 pub(super) fn child_install_enforcement(
3646 tier: EnforcementTier,
3647 user_ns_fd: RawFd,
3648 net_ns_fd: RawFd,
3649 sealed_spec: Option<&crate::sealed::SealedRootSpec>,
3650 landlock_fds: Option<&LandlockChildFds>,
3651 unix_sockets_allowed: bool,
3652 ) -> io::Result<()> {
3653 // FIRST, on every kernel tier: join the session's network namespace. It has to precede
3654 // everything below it for three separate reasons:
3655 //
3656 // * `SECCOMP_MUST_STAY_DENIED` denies `setns`, so the filter installed further down
3657 // closes the only window in which the join can happen — the same argument the
3658 // composed root makes for `unshare`.
3659 // * joining the user namespace hands this child every capability *inside* it, and
3660 // `drop_all_capabilities` (below) strips them again before `execve`. Joining after
3661 // that drop would fail: entering the network namespace needs `CAP_SYS_ADMIN` there.
3662 // * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests its user
3663 // namespace inside the session's. A task in a descendant user namespace holds no
3664 // capability in the ancestor, so the capsule cannot reconfigure the network namespace
3665 // it is confined to — which only holds if the join comes first.
3666 //
3667 // There is no path that skips it and falls back: a host that cannot create the namespace
3668 // was refused at launch (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`), and a
3669 // spawn whose namespace could not be established failed before the fork.
3670 //
3671 // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
3672 // documents itself as running in, and the one `join_session_netns` requires. Both
3673 // descriptors are held open by the join handle the `pre_exec` closure owns.
3674 unsafe { crate::network_namespace::join_session_netns(user_ns_fd, net_ns_fd) }.map_err(
3675 |error| {
3676 io::Error::other(format!(
3677 "sandbox: joining this session's network namespace failed: {error}"
3678 ))
3679 },
3680 )?;
3681
3682 // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
3683 // composed root. This has to precede every *remaining* step in this closure, because
3684 // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
3685 // the filter below covers, permanently and deliberately.
3686 // The composed root is therefore built by this process while it still has its pre-filter
3687 // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
3688 // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
3689 // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
3690 // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
3691 // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
3692 if tier == EnforcementTier::KernelSealed {
3693 if let Some(spec) = sealed_spec {
3694 #[cfg(test)]
3695 if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
3696 return Err(io::Error::other(format!(
3697 "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
3698 crate::sealed::SEALED_ROOT_FAILURE_PREFIX
3699 )));
3700 }
3701 crate::sealed::construct_composed_root(spec)
3702 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
3703 }
3704 }
3705
3706 // Before anything else in this window, on every kernel tier: shut the fd-inheritance
3707 // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
3708 // ruleset installed further down this function — it can do nothing about a descriptor
3709 // the child already holds open at that point, because that fd was opened before the
3710 // ruleset existed and there is no operation left for the ruleset to intercept. Until
3711 // this call existed, the only thing stopping a spawned shell from inheriting an
3712 // arbitrary open fd was the empirical accident that nothing else happened to be open in
3713 // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
3714 // config read, a lock file, a log handle — would have leaked silently into every
3715 // subsequent subprocess, on every platform and every tier, with no test to catch it.
3716 //
3717 // Running it here is also what makes it cover this function's *own* descriptors: the
3718 // diagnostic pipe and the Landlock grant fds are used below but must never survive the
3719 // `execve`, and neither may the session's namespace handles, already used above.
3720 mark_inherited_fds_cloexec()?;
3721
3722 // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
3723 // capability model are independent, both-must-allow gates, so narrowing the Landlock
3724 // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
3725 // must also run *before* `install_seccomp_filter`, because it is what sets
3726 // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
3727 // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
3728 // failure is its own distinct, fail-closed error path before any filter is installed.
3729 drop_all_capabilities()?;
3730
3731 // The child stays *non-dumpable*, inherited from the runtime process
3732 // (`security::harden_process_dumpable`). Nothing reads the child's memory, so nothing
3733 // needs it dumpable. Marking it dumpable opens same-uid `ptrace`, `environ` and core-dump
3734 // access for the whole life of every shell subprocess: do not do so without a reader that
3735 // requires it.
3736 install_seccomp_filter(unix_sockets_allowed)?;
3737
3738 if matches!(
3739 tier,
3740 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3741 ) {
3742 if let Some(fds) = landlock_fds {
3743 apply_landlock_scope(fds).map_err(io::Error::other)?;
3744 }
3745 }
3746
3747 Ok(())
3748 }
3749
3750 /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
3751 /// the only descriptors that survive into the exec'd program are the ones the runtime
3752 /// deliberately set up (stdio) rather than whatever happened to be open.
3753 ///
3754 /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
3755 /// the required semantics here, not a softer variant of it: several fds in this range are
3756 /// still needed *inside* this `pre_exec` window — the diagnostic pipe a failure further down
3757 /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules from. Closing
3758 /// them here would break the very enforcement setup that follows; flagging them means they
3759 /// keep working until `execve`, which then closes them for us.
3760 ///
3761 /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
3762 /// stdio decision and its reasoning.
3763 ///
3764 /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
3765 /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
3766 /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
3767 /// so every spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
3768 /// now fails rather than silently running without fd hygiene. That is deliberate and it is
3769 /// this module's established fail-closed discipline (`drop_all_capabilities`,
3770 /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
3771 /// error rather than degrade), but it is a real, user-visible narrowing of the supported
3772 /// kernel range for kernel-enforcement tiers.
3773 pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
3774 // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
3775 // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
3776 // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
3777 // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
3778 // spelling from `close_range(2)`.
3779 let rc = unsafe {
3780 libc::syscall(
3781 libc::SYS_close_range,
3782 super::FD_HYGIENE_FIRST_FD,
3783 libc::c_uint::MAX,
3784 libc::CLOSE_RANGE_CLOEXEC,
3785 )
3786 };
3787 if rc != 0 {
3788 return Err(io::Error::last_os_error());
3789 }
3790 Ok(())
3791 }
3792
3793 /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
3794 /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
3795 /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
3796 /// only capabilities 0–31 and make the kernel log a deprecation warning.
3797 const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
3798
3799 /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
3800 /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
3801 /// capabilities — so this can never under-cover. Numbers the running kernel does not define
3802 /// return `EINVAL`, which the loop skips.
3803 const CAP_LAST_CAP_FALLBACK: u32 = 63;
3804
3805 /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
3806 /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
3807 /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
3808 /// `msghdr`/`cmsghdr` handling.
3809 ///
3810 /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
3811 /// handed to `capset(2)`; Rust itself only ever writes them.
3812 #[repr(C)]
3813 #[allow(dead_code)]
3814 struct CapUserHeader {
3815 version: u32,
3816 pid: libc::c_int,
3817 }
3818
3819 /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
3820 /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
3821 /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
3822 /// note.
3823 #[repr(C)]
3824 #[derive(Clone, Copy)]
3825 #[allow(dead_code)]
3826 struct CapUserData {
3827 effective: u32,
3828 permitted: u32,
3829 inheritable: u32,
3830 }
3831
3832 /// Strips every Linux capability from the forked child before `execve()`, in three steps
3833 /// whose **order matters**:
3834 ///
3835 /// 1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
3836 /// still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
3837 /// `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
3838 /// 2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
3839 /// Shrinking your own permitted set to any subset — including empty — never requires
3840 /// `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
3841 /// inheritable also empties the *ambient* set, which the kernel maintains as a subset of
3842 /// both — that is what covers a non-root capsule handed an unexpected ambient
3843 /// `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
3844 /// 3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
3845 /// file-capability binary, and so the seccomp filter installed next can load without
3846 /// `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
3847 /// longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
3848 /// as a side effect.
3849 ///
3850 /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
3851 /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
3852 /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
3853 /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
3854 /// genuinely unprivileged caller, the bounding set would silently survive while this function
3855 /// still reported success.
3856 ///
3857 /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
3858 /// capability transition treats a file's permitted set as all-ones when the process's real
3859 /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
3860 /// would be undone by the very next `execve`; emptying the bounding set is what makes the
3861 /// post-exec permitted (and hence effective) set empty, which is what actually takes
3862 /// `CAP_MKNOD` away from the shell.
3863 ///
3864 /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
3865 /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
3866 /// — and strictly narrows what the subprocess can reach.
3867 fn drop_all_capabilities() -> io::Result<()> {
3868 drop_capability_bounding_set()?;
3869 clear_capability_sets()?;
3870 set_no_new_privs()
3871 }
3872
3873 /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
3874 ///
3875 /// - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
3876 /// - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
3877 /// process that never held the capability being dropped either. Hard-failing here would
3878 /// break every non-root `mur run`.
3879 ///
3880 /// Any other errno is unexpected and propagates, aborting the exec — this module's
3881 /// fail-closed invariant: no silently-unenforced spawn.
3882 #[allow(unsafe_code)]
3883 fn drop_capability_bounding_set() -> io::Result<()> {
3884 let zero: libc::c_ulong = 0;
3885
3886 for cap in 0..=kernel_last_cap() {
3887 let cap_arg = libc::c_ulong::from(cap);
3888 // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
3889 // ignored arguments — no pointers and no borrowed memory are involved.
3890 let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
3891 if rc == 0 {
3892 continue;
3893 }
3894
3895 let error = io::Error::last_os_error();
3896 match error.raw_os_error() {
3897 Some(libc::EINVAL) | Some(libc::EPERM) => continue,
3898 _ => return Err(error),
3899 }
3900 }
3901
3902 Ok(())
3903 }
3904
3905 /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
3906 /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
3907 /// everything. Any error is therefore unexpected and fails closed.
3908 #[allow(unsafe_code)]
3909 fn clear_capability_sets() -> io::Result<()> {
3910 let header = CapUserHeader {
3911 version: LINUX_CAPABILITY_VERSION_3,
3912 // 0 means "the calling thread" — the only pid `capset` accepts for a capability
3913 // change since Linux 2.6.25.
3914 pid: 0,
3915 };
3916 let data = [CapUserData {
3917 effective: 0,
3918 permitted: 0,
3919 inheritable: 0,
3920 }; 2];
3921
3922 // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
3923 // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
3924 // expect. The kernel only reads through them.
3925 let rc = unsafe {
3926 libc::syscall(
3927 libc::SYS_capset,
3928 &header as *const CapUserHeader,
3929 data.as_ptr(),
3930 )
3931 };
3932 if rc != 0 {
3933 return Err(io::Error::last_os_error());
3934 }
3935
3936 Ok(())
3937 }
3938
3939 /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
3940 /// process's privilege and is never refused for a well-formed call, so any error is
3941 /// unexpected and fails closed. Same call convention and fail-closed style as
3942 /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
3943 /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
3944 /// whole-process hardening.
3945 #[allow(unsafe_code)]
3946 fn set_no_new_privs() -> io::Result<()> {
3947 #[cfg(test)]
3948 if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
3949 return Err(io::Error::other(
3950 "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
3951 ));
3952 }
3953 // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
3954 // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
3955 // upper 32 bits would be unspecified in a variadic call on aarch64.
3956 let enable: libc::c_ulong = 1;
3957 let zero: libc::c_ulong = 0;
3958
3959 // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
3960 // and no borrowed memory are involved.
3961 let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
3962 if rc != 0 {
3963 return Err(io::Error::last_os_error());
3964 }
3965
3966 Ok(())
3967 }
3968
3969 /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
3970 /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
3971 /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
3972 /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
3973 /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
3974 #[allow(unsafe_code)]
3975 fn kernel_last_cap() -> u32 {
3976 const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
3977
3978 // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
3979 // only reads through the pointer.
3980 let fd = unsafe {
3981 libc::open(
3982 PATH.as_ptr() as *const libc::c_char,
3983 libc::O_RDONLY | libc::O_CLOEXEC,
3984 )
3985 };
3986 if fd < 0 {
3987 return CAP_LAST_CAP_FALLBACK;
3988 }
3989
3990 let mut buf = [0u8; 16];
3991 // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
3992 // is passed, so `read` cannot write past it.
3993 let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
3994 // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
3995 unsafe { libc::close(fd) };
3996
3997 if read <= 0 {
3998 return CAP_LAST_CAP_FALLBACK;
3999 }
4000
4001 let parsed = std::str::from_utf8(&buf[..read as usize])
4002 .ok()
4003 .and_then(|text| text.trim().parse::<u32>().ok());
4004
4005 match parsed {
4006 Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
4007 _ => CAP_LAST_CAP_FALLBACK,
4008 }
4009 }
4010
4011 /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
4012 /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
4013 /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
4014 /// does support Landlock, so this is a real combination, not a hypothetical one).
4015 ///
4016 /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
4017 /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
4018 /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
4019 const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
4020 ("landlock_create_ruleset", 444),
4021 ("landlock_add_rule", 445),
4022 ("landlock_restrict_self", 446),
4023 ];
4024
4025 /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
4026 /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
4027 /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
4028 /// held against and the headroom between them are recorded in
4029 /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4030 ///
4031 /// **This filter raises no notifications and needs no supervisor.** Network enforcement lives
4032 /// in [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
4033 /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
4034 /// `Notify` rule may come back.
4035 ///
4036 /// One mechanism plus a default:
4037 ///
4038 /// - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
4039 /// a plain integer argument, so the comparison compiles straight into the loaded BPF
4040 /// program and is evaluated in-kernel at syscall time: no notification is raised and no
4041 /// memory of another task is read. That is what makes it structurally immune to the
4042 /// pointer-read TOCTOU class of problem in the first place.
4043 /// - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
4044 /// neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
4045 /// argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
4046 /// `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
4047 /// unreachable — they are simply absent, not argument-matched.
4048 fn build_seccomp_filter(
4049 unix_sockets_allowed: bool,
4050 ) -> io::Result<libseccomp::ScmpFilterContext> {
4051 // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
4052 // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
4053 // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
4054 // call's arguments and refused it", `EPERM` means "this syscall is not part of the
4055 // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
4056 // unmediated-surface problem from a policy decision.
4057 let mut filter =
4058 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4059 .map_err(to_io_err)?;
4060
4061 // Ask the kernel to record every action this filter takes that is not `Allow` in the
4062 // audit trail, so a denial is attributable after the fact to a syscall number, pid and
4063 // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
4064 // has no channel for anything richer — so this is the only way an operator debugging a
4065 // workload that dies on an unexpected denial gets a syscall name to look at.
4066 //
4067 // Best-effort on purpose, and the one place in this function where an error is swallowed:
4068 // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
4069 // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
4070 // not an enforcement one — the default-deny action and every rule below are unaffected —
4071 // and turning it into a hard failure would take a host with an old libseccomp from
4072 // "denials are logged less legibly" to "no capsule can spawn a shell at all".
4073 //
4074 // Setting the attribute is necessary but not sufficient: the kernel only logs an action
4075 // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
4076 // configuration this process does not control.
4077 // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
4078 // these denials are verified by.
4079 let _ = filter.set_ctl_log(true);
4080
4081 // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
4082 // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
4083 // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
4084 // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
4085 // sees one consistent errno across both argument-level mechanisms.
4086 //
4087 // Untouched by the network-namespace work, and deliberately so: a network namespace does
4088 // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
4089 // still the only thing standing between a capsule and `/var/run/docker.sock`.
4090 //
4091 // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
4092 // adapt the rule to the architectures in the filter — on an arch where `socket` is
4093 // reached through `socketcall(2)` rather than as its own syscall, the exact form would
4094 // fail the whole install instead. Fail-closed is preserved either way, since any error
4095 // here propagates out of `pre_exec` and aborts the spawn.
4096 //
4097 // One rule per domain rather than one rule with three comparators: comparators within a
4098 // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
4099 // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
4100 let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
4101 for domain in super::denied_socket_domains(unix_sockets_allowed) {
4102 filter
4103 .add_rule_conditional(
4104 libseccomp::ScmpAction::Errno(libc::EACCES),
4105 socket_syscall,
4106 &[libseccomp::ScmpArgCompare::new(
4107 0,
4108 libseccomp::ScmpCompareOp::Equal,
4109 domain as u64,
4110 )],
4111 )
4112 .map_err(to_io_err)?;
4113 }
4114
4115 // The permitted domains need positive rules of their own now that the filter's default is
4116 // a deny — without them `socket()` would be refused for every family, IP included.
4117 //
4118 // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
4119 // libseccomp resolves an unconditional rule for a syscall by *discarding* every
4120 // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
4121 // but the new filter has no chains so the existing chains must be cleared"), so a broad
4122 // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
4123 // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
4124 // default profile carries the same warning against combining a broad `socket` allow with
4125 // explicit errno rules, and works around it the same way — with argument-matched rules
4126 // only. The two domain sets are disjoint by construction (a test pins that), so no
4127 // `socket()` call can match both an allow and a deny rule.
4128 for domain in super::allowed_socket_domains(unix_sockets_allowed) {
4129 filter
4130 .add_rule_conditional(
4131 libseccomp::ScmpAction::Allow,
4132 socket_syscall,
4133 &[libseccomp::ScmpArgCompare::new(
4134 0,
4135 libseccomp::ScmpCompareOp::Equal,
4136 domain as u64,
4137 )],
4138 )
4139 .map_err(to_io_err)?;
4140 }
4141
4142 // The allowlist proper. Two failure modes are both handled by skipping the name:
4143 //
4144 // - `from_name` fails when this host's libseccomp does not know the syscall (a name
4145 // newer than the installed library);
4146 // - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
4147 // not exist on this architecture — every legacy x86_64 spelling in the array
4148 // (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
4149 //
4150 // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
4151 // so the filter can only ever come out *narrower* than intended, never wider. Propagating
4152 // instead would abort every single spawn on the first arch-specific name, which is why
4153 // this is the deliberate exception to the module's otherwise-strict error handling.
4154 for name in super::SECCOMP_SYSCALL_ALLOWLIST {
4155 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
4156 let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
4157 }
4158 }
4159
4160 // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
4161 // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
4162 // deny would refuse the child's own filesystem scoping and abort every spawn on that
4163 // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
4164 // that installs it, never widen it, and Docker's default profile allows them too.
4165 //
4166 // Resolved by name where possible, with the syscall number as a fallback, because these
4167 // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
4168 // policy above would silently turn an older libseccomp into "no capsule can run a shell
4169 // on a Landlock-capable host". The numbers are the same on every architecture Linux has
4170 // added Landlock to (they were assigned after the syscall-number unification), so the
4171 // fallback is not architecture-specific.
4172 for (name, fallback_nr) in LANDLOCK_SYSCALLS {
4173 let syscall = libseccomp::ScmpSyscall::from_name(name)
4174 .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
4175 filter
4176 .add_rule(libseccomp::ScmpAction::Allow, syscall)
4177 .map_err(to_io_err)?;
4178 }
4179
4180 Ok(filter)
4181 }
4182
4183 /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
4184 /// the filter stays in force for the rest of this task's life and is inherited across the
4185 /// `execve` that follows, which is why nothing outside a forked child ever calls this.
4186 fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
4187 // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
4188 // on Drop) only frees the userspace filter-building context, while the filter itself is
4189 // already installed in the kernel and is inherited across the `execve` that follows.
4190 build_seccomp_filter(unix_sockets_allowed)?
4191 .load()
4192 .map_err(to_io_err)
4193 }
4194
4195 fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
4196 io::Error::other(error.to_string())
4197 }
4198
4199 /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
4200 /// instruction. Dividing an export's length by it is what turns bytes into an instruction
4201 /// count.
4202 #[cfg(test)]
4203 const SOCK_FILTER_BYTES: usize = 8;
4204
4205 /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
4206 /// returns what it wrote.
4207 ///
4208 /// Both exporters take a descriptor and nothing else, so the buffer has to come from
4209 /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
4210 /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
4211 /// crate and so is unavailable to anything outside a test build.
4212 #[cfg(test)]
4213 fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
4214 where
4215 F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
4216 {
4217 use std::io::{Read, Seek, SeekFrom};
4218 use std::os::fd::FromRawFd;
4219
4220 // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
4221 // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
4222 // the descriptor lives and dies inside this function.
4223 let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
4224 if raw < 0 {
4225 return Err(io::Error::last_os_error());
4226 }
4227 // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
4228 // is exactly the precondition for handing it to `OwnedFd`.
4229 let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
4230
4231 export(&memfd).map_err(to_io_err)?;
4232
4233 let mut file = std::fs::File::from(memfd);
4234 file.seek(SeekFrom::Start(0))?;
4235 let mut exported = Vec::new();
4236 file.read_to_end(&mut exported)?;
4237 Ok(exported)
4238 }
4239
4240 /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
4241 /// this host, for the given `unix_sockets_allowed`.
4242 ///
4243 /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
4244 /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
4245 /// divided by that record size. The number depends on the host — how many
4246 /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
4247 /// which is why it is measured here rather than written down.
4248 ///
4249 /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
4250 /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
4251 #[cfg(test)]
4252 fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
4253 let filter = build_seccomp_filter(unix_sockets_allowed)?;
4254 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
4255 Ok(exported.len() / SOCK_FILTER_BYTES)
4256 }
4257
4258 /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
4259 /// one block per syscall carrying a rule, each block naming the argument comparisons and the
4260 /// action. It is the only view of a built context that shows the rule *shape* rather than the
4261 /// compiled program's size, which is what a test pinning the rule sequence needs.
4262 #[cfg(test)]
4263 fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
4264 let filter = build_seccomp_filter(unix_sockets_allowed)?;
4265 let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
4266 String::from_utf8(exported).map_err(to_io_err)
4267 }
4268
4269 /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
4270 ///
4271 /// - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
4272 /// FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
4273 /// `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
4274 /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
4275 /// is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
4276 /// the path it resolved itself, so nothing the capsule writes into its workdir can run;
4277 /// - on `KernelSealed` only, the workdir-backed `/tmp` store gets that **same** right-set
4278 /// from the same local binding. `plan_composed_root` binds `<workdir>/.mur-tmp` at `/tmp`
4279 /// read-write, but Landlock matches an access to `/tmp/x` against the bind's own root inode
4280 /// rather than the workdir's, so without this rule the mount is writable and every access
4281 /// to it is nevertheless `EACCES` — bash heredocs, `mktemp`, a compiler's intermediate
4282 /// objects and a package manager's build scratch all fail. Identical rights are the honest
4283 /// ones: it is the same storage, counted by the same `workdir_max_bytes` guard, so it must
4284 /// be neither more nor less runnable than the workdir itself;
4285 /// - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
4286 /// shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
4287 /// `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
4288 /// [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
4289 /// grant — never write — with two bits added on top.
4290 /// Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
4291 /// exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
4292 /// file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
4293 /// whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
4294 /// — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
4295 /// (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
4296 /// `true` everywhere except the two tier-issued sealed grants, which are deliberately
4297 /// readable (and, on a directory, enumerable) but not runnable;
4298 /// - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
4299 /// and `Truncate` if its `writable` bit is set. That bit is set for exactly one path —
4300 /// `/dev/null` — which is therefore the *only* writable path outside the workdir in the
4301 /// whole sandbox. This list is a compile-time constant, not manifest-derived: no capability
4302 /// declaration widens or narrows it. Every device path it does not name stays denied by the
4303 /// ordinary no-matching-rule path below.
4304 ///
4305 /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
4306 /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
4307 /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
4308 /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
4309 /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
4310 /// so a write to any other outside path — including one of these read+execute grant paths — is
4311 /// still denied.
4312 ///
4313 /// The ruleset is built at `fds.abi` — the kernel's ABI capped at
4314 /// [`super::LANDLOCK_TARGET_ABI`], decided in the parent — or at ABI v1 when that is `None`.
4315 /// `handle_access` declares the **full** `from_all` set of that ABI, under `CompatLevel::HardRequirement`, and every rule's
4316 /// rights are masked to it first: a right the ABI does not know would otherwise fail
4317 /// `add_rule`, and masking it out is what a kernel of that ABI would enforce anyway. Built at
4318 /// v1, the ruleset is bit for bit the one a 5.13–5.18 kernel gets.
4319 ///
4320 /// `handle_access` must declare the union of every access bit any rule uses, and it is the
4321 /// full set deliberately: a bit declared there but granted by no rule is denied for
4322 /// the whole domain, which is exactly how the rights missing from
4323 /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
4324 /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
4325 /// path, on a host where one of the three is missing — that
4326 /// fails to *open* is skipped
4327 /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
4328 /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
4329 /// preserving the module's fail-closed invariant.
4330 fn apply_landlock_scope(fds: &LandlockChildFds) -> Result<(), String> {
4331 #[cfg(test)]
4332 if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
4333 return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
4334 }
4335
4336 use landlock::{
4337 Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
4338 RulesetCreatedAttr, ABI,
4339 };
4340
4341 let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
4342 let handled = AccessFs::from_all(abi);
4343 // Enough for the loader to open, read, and map-execute a binary or shared library, and to
4344 // open a file inside a granted directory by exact name — but no write access, and (without
4345 // `ReadDir`) no enumeration of the directory's own entries.
4346 let read_execute = AccessFs::Execute | AccessFs::ReadFile;
4347 // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
4348 let read_execute_list = read_execute | AccessFs::ReadDir;
4349 // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
4350 // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
4351 // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
4352 let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
4353 // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
4354 // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
4355 // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
4356 let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
4357 // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
4358 // not a directory) — only the data bits, with `WriteFile` reserved for the writable
4359 // devices. `Truncate` rides with `WriteFile` because a shell's `>/dev/null` opens with
4360 // `O_TRUNC`, and handling `Truncate` from ABI v3 would otherwise refuse it.
4361 let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
4362 let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
4363
4364 // The one writable right-set in this function, resolved once and used by both rules that
4365 // carry it. The composed root's `/tmp` is a bind of a directory inside the workdir — the
4366 // same storage, under the same size guard — so it gets the same rights, `Execute` axis
4367 // included: `workdir_exec: false` must no more permit running a binary from `/tmp` than
4368 // from the workdir it is stored in. Sharing the binding is what keeps that true; two
4369 // separate calls could drift apart.
4370 let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
4371
4372 // Every fd here was already opened in the parent (before fork). This function performs no
4373 // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
4374 // so a failure here can only be the kernel call itself, not an unrelated path resolution.
4375 let mut ruleset = Ruleset::default()
4376 .set_compatibility(CompatLevel::HardRequirement)
4377 .handle_access(handled)
4378 .map_err(|error| format!("landlock: handle_access failed: {error}"))?
4379 .create()
4380 .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
4381 .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
4382 .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
4383
4384 // `KernelSealed` only — on every other tier there is no composed root and this is `None`.
4385 // A rule on the workdir alone does not reach the bind mounted over `/tmp`; see
4386 // [`LandlockChildFds::sealed_tmp_fd`].
4387 if let Some(sealed_tmp_fd) = &fds.sealed_tmp_fd {
4388 ruleset = ruleset
4389 .add_rule(PathBeneath::new(sealed_tmp_fd.as_fd(), writable_rights))
4390 .map_err(|error| {
4391 format!("landlock: add_rule for the workdir-backed /tmp failed: {error}")
4392 })?;
4393 }
4394
4395 // The runtime directory: readable and listable, never writable or executable. A native
4396 // tool's entrypoint inside it runs through its own pinned `Execute` grant.
4397 if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
4398 ruleset = ruleset
4399 .add_rule(PathBeneath::new(
4400 runtime_dir_fd.as_fd(),
4401 read_list & handled,
4402 ))
4403 .map_err(|error| {
4404 format!("landlock: add_rule for the runtime directory failed: {error}")
4405 })?;
4406 }
4407
4408 // The records directory, on the same terms: the runtime appends to the trace from
4409 // outside the domain, and no command inside it can.
4410 if let Some(records_dir_fd) = &fds.records_dir_fd {
4411 ruleset = ruleset
4412 .add_rule(PathBeneath::new(
4413 records_dir_fd.as_fd(),
4414 read_list & handled,
4415 ))
4416 .map_err(|error| {
4417 format!("landlock: add_rule for the records directory failed: {error}")
4418 })?;
4419 }
4420
4421 // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
4422 // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
4423 // the grant loop's rules for those two paths are taken on the host's files, not on these.
4424 for identity_fd in &fds.sealed_identity_fds {
4425 ruleset = ruleset
4426 .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
4427 .map_err(|error| {
4428 format!("landlock: add_rule for a synthetic /etc file failed: {error}")
4429 })?;
4430 }
4431
4432 for grant in &fds.grants {
4433 // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
4434 // so every fd reaching here is valid. A rule that still fails to add is a genuine
4435 // ruleset-construction failure and propagates as `Err` (fail-closed).
4436 let access = match (grant.executable, grant.list_dir) {
4437 (true, true) => read_execute_list,
4438 (true, false) => read_execute,
4439 (false, true) => read_list,
4440 (false, false) => read_only,
4441 };
4442 ruleset = ruleset
4443 .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
4444 .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
4445 }
4446
4447 for device in &fds.devices {
4448 // Same convention as the grant loop above: a device that failed to open in the parent
4449 // was already dropped, so a failure here is a genuine ruleset-construction failure and
4450 // propagates as `Err` (fail-closed).
4451 let access = if device.writable {
4452 device_read_write
4453 } else {
4454 device_read
4455 };
4456 ruleset = ruleset
4457 .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
4458 .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
4459 }
4460
4461 ruleset
4462 .restrict_self()
4463 .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
4464
4465 Ok(())
4466 }
4467
4468 /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
4469 /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
4470 /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
4471 /// now fails here, synchronously, with a message naming the path — before any subprocess is
4472 /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
4473 /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
4474 ///
4475 /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
4476 /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
4477 /// only, never widening.
4478 ///
4479 /// `device_grants` — the tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on
4480 /// `KernelFull` and the composed root's own OCI default set on `KernelSealed`, chosen by
4481 /// `super::landlock_device_grants` — is opened here too, by the same rules: the list is a
4482 /// compile-time constant rather than manifest-derived, and a host where one entry cannot be
4483 /// opened — unusual, but a broken or minimal `/dev` is possible — loses that one device rather
4484 /// than failing the launch. Losing `/dev/null` here degrades to exactly the behavior that
4485 /// existed before this mechanism, which is a strictly narrower scope, never a wider one.
4486 ///
4487 /// `sealed` carries what only `KernelSealed` opens: the workdir-backed `/tmp` store
4488 /// ([`crate::sealed::SEALED_TMP_DIR_NAME`]), whose descriptor `prepare_enforcement` already
4489 /// took and shares with the bind, and the synthetic `/etc` files. `runtime_dir_fd` and
4490 /// `records_dir_fd` are the runtime and records directories' descriptors on both Landlock
4491 /// tiers. Each is fail-closed — an `Err` naming the path, exactly like the workdir's own open
4492 /// above, never the grant loops' shrink-not-fail `continue`.
4493 #[allow(clippy::too_many_arguments)]
4494 pub(super) fn open_landlock_fds(
4495 accessible_workdir: &Path,
4496 landlock_grants: &[LandlockGrant],
4497 device_grants: &[super::CapsuleDeviceGrant],
4498 workdir_exec: bool,
4499 abi: Option<u8>,
4500 sealed: Option<&super::SealedSourceFds>,
4501 runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4502 records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4503 ) -> Result<LandlockChildFds, String> {
4504 let workdir_fd = open_o_path(accessible_workdir)
4505 .map(OwnedFd::from)
4506 .map_err(|error| {
4507 format!(
4508 "sandbox: failed to open workdir {} for Landlock scoping: {error}",
4509 accessible_workdir.display()
4510 )
4511 })?;
4512
4513 let sealed_tmp_fd = sealed.map(|sources| std::sync::Arc::clone(&sources.tmp_store));
4514
4515 // Same tier gate, same fail-closed handling: the composed root binds these two files at
4516 // `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of those
4517 // names, whose inodes the capsule never sees. See `LandlockChildFds`. `sealed-etc/` is
4518 // written by the runtime alone.
4519 let mut sealed_identity_fds = Vec::new();
4520 for path in sealed
4521 .map(|sources| sources.identity_files.as_slice())
4522 .unwrap_or_default()
4523 {
4524 let file = open_o_path(path).map_err(|error| {
4525 format!(
4526 "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
4527 {error}",
4528 path.display()
4529 )
4530 })?;
4531 sealed_identity_fds.push(OwnedFd::from(file));
4532 }
4533
4534 let mut grants = Vec::with_capacity(landlock_grants.len());
4535 for grant in landlock_grants {
4536 match open_o_path(&grant.path) {
4537 Ok(file)
4538 if grant.pinned.is_some_and(|pinned| {
4539 file.metadata()
4540 .map(|meta| super::FileIdentity::of(&meta) != pinned)
4541 .unwrap_or(true)
4542 }) =>
4543 {
4544 continue
4545 }
4546 Ok(file) => grants.push(OpenLandlockGrant {
4547 // `ReadDir` is meaningful only on a directory, and the kernel does not merely
4548 // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
4549 // directory-only right on a non-directory, and the rule loop in
4550 // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
4551 // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
4552 // `interpreter_runtime` entry, or the static `directory` bit on a
4553 // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
4554 // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
4555 // never add it.
4556 list_dir: grant.list_dir
4557 && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
4558 fd: OwnedFd::from(file),
4559 executable: grant.executable,
4560 }),
4561 Err(_) => continue,
4562 }
4563 }
4564
4565 let mut devices = Vec::with_capacity(device_grants.len());
4566 for device in device_grants {
4567 match open_o_path(Path::new(device.path)) {
4568 Ok(file) => devices.push(OpenDeviceGrant {
4569 fd: OwnedFd::from(file),
4570 writable: device.writable,
4571 }),
4572 Err(_) => continue,
4573 }
4574 }
4575
4576 Ok(LandlockChildFds {
4577 workdir_fd,
4578 workdir_exec,
4579 abi,
4580 sealed_tmp_fd,
4581 sealed_identity_fds,
4582 runtime_dir_fd,
4583 records_dir_fd,
4584 grants,
4585 devices,
4586 })
4587 }
4588
4589 /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
4590 /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
4591 /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
4592 /// closure.
4593 ///
4594 /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
4595 /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
4596 /// not perform a second path lookup to get it.
4597 fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
4598 use std::os::unix::fs::OpenOptionsExt;
4599 std::fs::OpenOptions::new()
4600 .read(true)
4601 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
4602 .open(path)
4603 }
4604
4605 /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
4606 /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
4607 /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
4608 /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
4609 /// returns `Err`, so `execve` is still aborted).
4610 #[allow(unsafe_code)]
4611 pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
4612 let bytes = message.as_bytes();
4613 let len = bytes.len().min(MAX_DIAG_LEN);
4614 let mut written = 0;
4615 while written < len {
4616 // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
4617 // name a live sub-slice of `bytes` for the duration of this single `write` call.
4618 let n = unsafe {
4619 libc::write(
4620 fd,
4621 bytes[written..len].as_ptr() as *const libc::c_void,
4622 len - written,
4623 )
4624 };
4625 if n < 0 {
4626 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
4627 continue;
4628 }
4629 break;
4630 }
4631 if n == 0 {
4632 break;
4633 }
4634 written += n as usize;
4635 }
4636 }
4637
4638 /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
4639 /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
4640 /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
4641 /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
4642 /// failure that never ran `pre_exec`).
4643 #[allow(unsafe_code)]
4644 pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
4645 let mut buf = Vec::new();
4646 let mut chunk = [0u8; 256];
4647 loop {
4648 // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
4649 // stack buffer of exactly the length passed.
4650 let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
4651 if n < 0 {
4652 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
4653 continue;
4654 }
4655 break;
4656 }
4657 if n == 0 {
4658 break;
4659 }
4660 buf.extend_from_slice(&chunk[..n as usize]);
4661 if buf.len() >= MAX_DIAG_LEN {
4662 break;
4663 }
4664 }
4665 if buf.is_empty() {
4666 None
4667 } else {
4668 Some(String::from_utf8_lossy(&buf).into_owned())
4669 }
4670 }
4671
4672 /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
4673 /// against — all four numbers produced by running code on the host under test, never quoted.
4674 ///
4675 /// The written record these tests feed is
4676 /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4677 ///
4678 /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
4679 /// irreversible for the life of the task, so an in-process load would silently apply to the
4680 /// rest of the test binary and to everything it spawns. Every load happens in a forked child
4681 /// that does nothing else and then `_exit`s.
4682 #[cfg(test)]
4683 #[allow(clippy::print_stdout, clippy::print_stderr)]
4684 mod seccomp_budget {
4685 use std::io;
4686 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
4687
4688 // `seccomp_filter_instruction_count` is absent from this list because the test that
4689 // reports it carries the same name; it is called as `super::` throughout.
4690 use super::{
4691 build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
4692 LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
4693 };
4694 use crate::sandbox::{
4695 allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
4696 EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
4697 SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
4698 };
4699
4700 /// The kernel charges each filter already attached to a task four instructions on top of
4701 /// its own length when it decides whether the next attach fits under
4702 /// [`SECCOMP_MAX_INSNS_PER_PATH`].
4703 ///
4704 /// It is only part of the arithmetic. The length the kernel adds up is that of the
4705 /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
4706 /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
4707 /// out slightly longer. So a load count predicted from the exported classic length is an
4708 /// upper bound on the load count the kernel actually allows, never an equality, which is
4709 /// why the cumulative probe brackets it rather than pinning it.
4710 const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
4711
4712 /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
4713 /// Every real errno is a small positive number, and 0 is reserved for a load that
4714 /// succeeded, so this cannot collide with either.
4715 const PROBE_ERRNO_UNKNOWN: i32 = 254;
4716
4717 /// A filter built only to be measured or refused: default `Allow`, and `rules`
4718 /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
4719 ///
4720 /// Default `Allow` rather than the real filter's default deny, for two reasons that both
4721 /// matter. A child has to survive its own load long enough to `_exit` and, in the
4722 /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
4723 /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
4724 /// equals the default action, so the rules have to carry an action the default does not:
4725 /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
4726 /// every rule in the program.
4727 ///
4728 /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
4729 /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
4730 /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
4731 fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
4732 let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
4733 .map_err(to_io_err)?;
4734 filter.set_api_sysrawrc(true).map_err(to_io_err)?;
4735
4736 let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
4737 for value in 0..rules {
4738 filter
4739 .add_rule_conditional(
4740 libseccomp::ScmpAction::Errno(libc::EPERM),
4741 syscall,
4742 &[libseccomp::ScmpArgCompare::new(
4743 0,
4744 libseccomp::ScmpCompareOp::Equal,
4745 value as u64,
4746 )],
4747 )
4748 .map_err(to_io_err)?;
4749 }
4750
4751 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
4752 Ok((filter, exported.len() / SOCK_FILTER_BYTES))
4753 }
4754
4755 /// The smallest probe-filter rule count whose compiled program is longer than
4756 /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
4757 /// monotonically with the rule count, so both halves are sound and the whole search
4758 /// costs a couple of dozen exports rather than thousands.
4759 fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
4760 let mut under = 1;
4761 let mut over = 8;
4762 while probe_filter(over)?.1 <= instructions {
4763 under = over;
4764 over *= 2;
4765 assert!(
4766 over <= 1 << 20,
4767 "no probe filter of up to {over} rules exceeded {instructions} instructions — \
4768 the probe is not growing the program the way this search assumes"
4769 );
4770 }
4771 while over - under > 1 {
4772 let mid = under + (over - under) / 2;
4773 if probe_filter(mid)?.1 <= instructions {
4774 under = mid;
4775 } else {
4776 over = mid;
4777 }
4778 }
4779 Ok(over)
4780 }
4781
4782 /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
4783 /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
4784 /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
4785 /// only ever sees a plain `libc` value.
4786 fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
4787 use libseccomp::error::SeccompErrno;
4788
4789 if let Some(raw) = error.sysrawrc() {
4790 return -raw;
4791 }
4792 match error.errno() {
4793 Some(SeccompErrno::EACCES) => libc::EACCES,
4794 Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
4795 Some(SeccompErrno::EINVAL) => libc::EINVAL,
4796 Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
4797 _ => PROBE_ERRNO_UNKNOWN,
4798 }
4799 }
4800
4801 /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
4802 /// that took, `Err(errno)` for one the kernel refused.
4803 ///
4804 /// The filter is built by the caller, before the fork, so the child's whole post-fork
4805 /// window is `load()` and `_exit`.
4806 fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
4807 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
4808 // child confines itself to work that does not depend on another thread's state. This
4809 // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
4810 // the BPF program on every load and does not cache the one `export_bpf` produced —
4811 // so this probe is not async-signal-safe in the strict sense; it is bounded to a
4812 // child that touches nothing else and dies immediately.
4813 let pid = unsafe { libc::fork() };
4814 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
4815
4816 if pid == 0 {
4817 let code = match filter.load() {
4818 Ok(()) => 0,
4819 Err(error) => refusal_errno(&error),
4820 };
4821 // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4822 // which is what keeps libtest's state in the parent untouched.
4823 unsafe { libc::_exit(code) }
4824 }
4825
4826 let mut status: libc::c_int = 0;
4827 // SAFETY: `pid` is the child just forked; `status` is a live local.
4828 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
4829 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
4830 assert!(
4831 libc::WIFEXITED(status),
4832 "the load probe child did not exit normally (status {status})"
4833 );
4834 match libc::WEXITSTATUS(status) {
4835 0 => Ok(()),
4836 errno => Err(errno),
4837 }
4838 }
4839
4840 /// The number of distinct syscalls the allowlist actually contributes a rule for on this
4841 /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
4842 /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
4843 /// is an independent measurement rather than a restatement of the filter under test.
4844 fn resolvable_allowlist_syscalls() -> io::Result<usize> {
4845 use std::collections::HashSet;
4846
4847 let mut scratch =
4848 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4849 .map_err(to_io_err)?;
4850 let mut accepted = HashSet::new();
4851 for name in SECCOMP_SYSCALL_ALLOWLIST {
4852 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
4853 if scratch
4854 .add_rule(libseccomp::ScmpAction::Allow, syscall)
4855 .is_ok()
4856 {
4857 accepted.insert(i32::from(syscall));
4858 }
4859 }
4860 }
4861 Ok(accepted.len())
4862 }
4863
4864 /// The line both ceiling probes print instead of running, on a host where no seccomp
4865 /// filter can be loaded at all.
4866 fn skip_without_seccomp(what: &str) -> bool {
4867 let tier = detect_enforcement_tier();
4868 if tier == EnforcementTier::EnvironmentOnly {
4869 eprintln!(
4870 "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
4871 can load a seccomp filter at all, which is every tier above \
4872 EnforcementTier::EnvironmentOnly; detected {tier:?}"
4873 );
4874 return true;
4875 }
4876 false
4877 }
4878
4879 /// What the real filter compiles to, against the budget this project holds itself to and
4880 /// the ceiling the kernel holds it to.
4881 ///
4882 /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
4883 /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
4884 /// cover the larger of the two.
4885 #[test]
4886 fn seccomp_filter_instruction_count() {
4887 for unix_sockets_allowed in [false, true] {
4888 let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
4889 .expect("export the built seccomp filter");
4890
4891 eprintln!(
4892 "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
4893 -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
4894 per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
4895 cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
4896 BPF_MAXINSNS_CEILING as f64 / count as f64,
4897 SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
4898 );
4899
4900 assert!(
4901 count > 0,
4902 "the exported filter is empty, so nothing was measured — \
4903 `export_bpf` wrote no `struct sock_filter` records at all"
4904 );
4905 assert!(
4906 count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
4907 "the child's seccomp filter compiles to {count} instructions with \
4908 unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
4909 {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
4910 {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
4911 still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
4912 deliberately — updating \
4913 docs/content/reference/seccomp-filter-instruction-budget.md with the new \
4914 numbers — rather than letting the filter grow into a spawn failure"
4915 );
4916 }
4917 }
4918
4919 /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
4920 /// pseudo-filter-code export so a change to the shape is a test failure rather than a
4921 /// silent change of policy.
4922 ///
4923 /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
4924 /// anywhere in this function would make libseccomp discard every argument-conditional
4925 /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
4926 /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
4927 #[test]
4928 fn seccomp_filter_rule_shape() {
4929 let allowlist_syscalls =
4930 resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
4931 assert!(
4932 allowlist_syscalls > 0,
4933 "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
4934 for the wrong reason"
4935 );
4936
4937 for unix_sockets_allowed in [false, true] {
4938 let filter =
4939 build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
4940 assert_eq!(
4941 filter.get_act_default().expect("read the default action"),
4942 libseccomp::ScmpAction::Errno(libc::EPERM),
4943 "the filter's default action is no longer a deny with EPERM, which is what \
4944 makes every syscall outside the allowlist unreachable"
4945 );
4946
4947 let pfc =
4948 seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
4949 let denied = denied_socket_domains(unix_sockets_allowed);
4950 let allowed = allowed_socket_domains(unix_sockets_allowed);
4951
4952 // One block per syscall carrying at least one rule: `socket`, every allowlist
4953 // name libseccomp accepted, and the three Landlock syscalls.
4954 let syscall_blocks = pfc.matches("# filter for syscall ").count();
4955 assert_eq!(
4956 syscall_blocks,
4957 1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
4958 "the built filter carries rules for {syscall_blocks} syscalls, not the \
4959 socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
4960 syscalls this code adds",
4961 LANDLOCK_SYSCALLS.len()
4962 );
4963
4964 let deny_actions = pfc
4965 .matches(&format!("action ERRNO({});", libc::EACCES))
4966 .count();
4967 assert_eq!(
4968 deny_actions,
4969 denied.len(),
4970 "the built filter carries {deny_actions} EACCES rules, not one per denied \
4971 socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
4972 exactly like this, and would have discarded every domain denial with it"
4973 );
4974
4975 // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
4976 // contributes one per allowed domain.
4977 let allow_actions = pfc.matches("action ALLOW;").count();
4978 assert_eq!(
4979 allow_actions,
4980 allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
4981 "the built filter carries {allow_actions} Allow rules, not one per allowed \
4982 socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
4983 Landlock syscall"
4984 );
4985
4986 for (name, _) in LANDLOCK_SYSCALLS {
4987 assert!(
4988 pfc.contains(&format!("\"{name}\"")),
4989 "{name} has no rule in the built filter, so `apply_landlock_scope` would \
4990 be refused by the filter installed just before it"
4991 );
4992 }
4993
4994 eprintln!(
4995 "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
4996 default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
4997 {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
4998 denied.len(),
4999 allowed.len(),
5000 LANDLOCK_SYSCALLS.len(),
5001 );
5002 }
5003 }
5004
5005 /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
5006 /// two filters that straddle it and observing that the kernel takes one and refuses the
5007 /// other with `EINVAL`.
5008 ///
5009 /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
5010 /// ceiling, whatever the filter's length.
5011 #[test]
5012 fn seccomp_per_filter_ceiling() {
5013 if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
5014 return;
5015 }
5016
5017 let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
5018 .expect("bracket the per-filter ceiling");
5019 let (under_filter, under_count) =
5020 probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
5021 let (over_filter, over_count) =
5022 probe_filter(over_rules).expect("build the over-ceiling probe filter");
5023
5024 assert!(
5025 under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
5026 "the two probe filters ({under_count} and {over_count} instructions) do not \
5027 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
5028 nothing about where the ceiling is"
5029 );
5030
5031 let under_outcome = load_in_child(&under_filter);
5032 let over_outcome = load_in_child(&over_filter);
5033
5034 eprintln!(
5035 "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
5036 instructions; smallest that did not = {over_count} instructions, refused with \
5037 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
5038 over_outcome.err().unwrap_or(0),
5039 libc::EINVAL,
5040 );
5041
5042 assert_eq!(
5043 under_outcome,
5044 Ok(()),
5045 "a {under_count}-instruction filter was refused, below the \
5046 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
5047 );
5048 assert_eq!(
5049 over_outcome,
5050 Err(libc::EINVAL),
5051 "a {over_count}-instruction filter was not refused with EINVAL, so \
5052 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
5053 per-filter limit"
5054 );
5055 }
5056
5057 /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
5058 /// task carries, by stacking filters in a forked child until the kernel refuses.
5059 ///
5060 /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
5061 /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
5062 /// filter of a few hundred instructions, and that had not yet reached `load()` when it
5063 /// failed, did not come from the kernel's seccomp code.
5064 #[test]
5065 fn seccomp_cumulative_ceiling() {
5066 if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
5067 return;
5068 }
5069
5070 // A probe of roughly the shipped filter's size, so the number of stacked loads reads
5071 // directly as "how many filters like ours one task could carry".
5072 let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
5073 let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
5074 // Enough headroom past the ceiling that a kernel which never refuses is caught by the
5075 // cap rather than by the test hanging.
5076 let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
5077
5078 let mut fds = [0 as libc::c_int; 2];
5079 // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
5080 let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
5081 assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
5082 // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
5083 let (read_fd, write_fd) =
5084 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
5085
5086 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
5087 // child confines itself to work that does not depend on another thread's state. This
5088 // child calls `load()` in a loop, writes eight bytes and `_exit`s.
5089 let pid = unsafe { libc::fork() };
5090 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5091
5092 if pid == 0 {
5093 let mut loads: u32 = 0;
5094 let mut refusal: i32 = 0;
5095 while loads < max_loads as u32 {
5096 match filter.load() {
5097 Ok(()) => loads += 1,
5098 Err(error) => {
5099 refusal = refusal_errno(&error);
5100 break;
5101 }
5102 }
5103 }
5104
5105 let mut report = [0u8; 8];
5106 report[..4].copy_from_slice(&loads.to_ne_bytes());
5107 report[4..].copy_from_slice(&refusal.to_ne_bytes());
5108 // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
5109 // local of exactly the length passed, and the descriptor is the pipe's write end.
5110 unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
5111 // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
5112 unsafe { libc::_exit(0) }
5113 }
5114
5115 drop(write_fd);
5116 let mut report = [0u8; 8];
5117 std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
5118 .expect("read the cumulative probe child's report");
5119 let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
5120 let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
5121
5122 let mut status: libc::c_int = 0;
5123 // SAFETY: `pid` is the child just forked; `status` is a live local.
5124 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5125 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5126
5127 let stacked = loads * count;
5128 let cumulative_at_refusal = count + loads * per_filter_cost;
5129 let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
5130 let shipped = super::seccomp_filter_instruction_count(false)
5131 .expect("measure the shipped filter for comparison");
5132
5133 eprintln!(
5134 "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
5135 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
5136 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
5137 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
5138 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
5139 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
5140 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
5141 instructions, so reaching this ceiling would take {} of them in a single task.",
5142 loads + 1,
5143 libc::ENOMEM,
5144 SECCOMP_MAX_INSNS_PER_PATH / shipped,
5145 );
5146
5147 assert!(
5148 loads >= 2,
5149 "the kernel refused the filter chain after only {loads} load(s), so this is not \
5150 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
5151 );
5152 assert_eq!(
5153 refusal,
5154 libc::ENOMEM,
5155 "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
5156 {refusal}, not ENOMEM ({}) — the rule-out in \
5157 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
5158 being what this ceiling returns",
5159 libc::ENOMEM
5160 );
5161 assert!(
5162 loads <= loads_if_counted_classic,
5163 "the kernel took {loads} loads of a {count}-instruction filter — more than the \
5164 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
5165 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
5166 each one, so this kernel's ceiling is higher than the recorded value"
5167 );
5168 // Loose on purpose: the exported classic length under-counts what the kernel adds
5169 // up, so the bound below tolerates any plausible conversion expansion while still
5170 // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
5171 // say, BPF_MAXINSNS would fail here.
5172 assert!(
5173 loads * 2 > loads_if_counted_classic,
5174 "the kernel refused after {loads} loads of a {count}-instruction filter, far \
5175 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
5176 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
5177 one recorded"
5178 );
5179 assert!(
5180 shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
5181 "the shipped filter ({shipped} instructions) is within a factor of two of the \
5182 cumulative ceiling, which would make the rule-out this test supports untrue"
5183 );
5184 }
5185 }
5186}
5187
5188#[cfg(test)]
5189mod tests {
5190 use super::*;
5191 use murmur_artifact::security_warnings::W_SEC_003;
5192
5193 /// Content check only: this asserts the *constant*, not the security property. Spawning a
5194 /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
5195 /// enforcement, so it would read as evidence while proving nothing.
5196 ///
5197 /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
5198 /// breaks every subprocess invocation.
5199 #[test]
5200 fn fd_hygiene_range_starts_above_stdio() {
5201 assert_eq!(
5202 FD_HYGIENE_FIRST_FD, 3,
5203 "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
5204 survive execve"
5205 );
5206 }
5207
5208 /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
5209 /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
5210 /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
5211 /// runs without fd hygiene.
5212 ///
5213 /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
5214 /// argument validation before it touches any descriptor — so this exercises the errno path
5215 /// without closing or flagging a single fd of the test process. A kernel older than 5.11
5216 /// rejects the production call with a different errno and the identical convention — see the
5217 /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
5218 #[cfg(target_os = "linux")]
5219 #[allow(unsafe_code)]
5220 #[test]
5221 fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
5222 // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
5223 // bit is set here, so the kernel rejects the call on argument validation and no descriptor
5224 // of this process is closed or marked close-on-exec.
5225 let rc = unsafe {
5226 libc::syscall(
5227 libc::SYS_close_range,
5228 FD_HYGIENE_FIRST_FD,
5229 libc::c_uint::MAX,
5230 libc::c_uint::MAX,
5231 )
5232 };
5233
5234 assert_eq!(rc, -1, "an invalid flag word must not be accepted");
5235 assert_eq!(
5236 std::io::Error::last_os_error().raw_os_error(),
5237 Some(libc::EINVAL),
5238 "the two statements the production path runs on failure must carry the kernel's errno"
5239 );
5240 }
5241
5242 /// A host that can back the sealed mechanism: AppArmor out of the way, and a namespace probe
5243 /// that really created one.
5244 fn sealed_capable() -> crate::sealed::SealedProbe {
5245 crate::sealed::SealedProbe {
5246 userns_grant: crate::sealed::UsernsGrant::ProfileConfining,
5247 namespace: crate::sealed::NamespaceProbe::Ok,
5248 }
5249 }
5250
5251 /// A host that cannot: the default, which claims nothing.
5252 fn sealed_incapable() -> crate::sealed::SealedProbe {
5253 crate::sealed::SealedProbe::default()
5254 }
5255
5256 /// A probe that learned nothing: no Landlock answer and a sealed half that claims nothing.
5257 /// The reading a host gives when neither half of the probe could run.
5258 fn unprobed() -> HostProbe {
5259 HostProbe::from_parts(None, crate::sealed::SealedProbe::default())
5260 }
5261
5262 /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
5263 /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
5264 /// tier is decided from a reading.
5265 #[test]
5266 fn an_unprobed_host_resolves_to_seccomp_only() {
5267 let tier = unprobed().tier();
5268 if cfg!(target_os = "linux") {
5269 assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
5270 } else {
5271 assert_eq!(tier, EnforcementTier::EnvironmentOnly);
5272 }
5273 assert_ne!(tier, EnforcementTier::KernelSealed);
5274 assert_ne!(tier, EnforcementTier::KernelFull);
5275 }
5276
5277 /// A reading that demonstrated nothing must still name what is missing: a refusal with no
5278 /// blocker is a refusal an operator cannot act on.
5279 #[test]
5280 fn an_unprobed_host_names_a_blocker() {
5281 assert!(unprobed().sealed_blocker().is_some());
5282 }
5283
5284 /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
5285 /// so nothing downstream can read a failed probe as a met floor.
5286 #[test]
5287 fn an_unprobed_host_achieves_less_than_sealed() {
5288 let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
5289 assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
5290 }
5291
5292 /// The whole point of the direction: a capsule declaring `sealed` against a reading that
5293 /// demonstrated nothing is refused, with the shortfall naming the mechanism.
5294 #[test]
5295 fn an_unprobed_host_refuses_a_declared_sealed_floor() {
5296 let probe = unprobed();
5297 let achieved = crate::containment::achieved_class_for_tier(probe.tier());
5298 let error = crate::containment::check_containment_floor(
5299 murmur_artifact::ContainmentClass::Sealed,
5300 achieved,
5301 probe.sealed_blocker(),
5302 false,
5303 None,
5304 )
5305 .expect_err("an unprobed host cannot back a declared sealed floor");
5306 assert!(matches!(
5307 error,
5308 crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
5309 ));
5310 }
5311
5312 /// The tier decision reads only `UsernsGrant::permits_userns()`, so every grant that permits
5313 /// the namespace has to reach the same tier — otherwise reporting provenance would have
5314 /// changed an outcome, which is exactly what naming the grant set out not to do.
5315 #[test]
5316 fn tier_from_probe_is_identical_across_every_permitting_userns_grant() {
5317 use crate::sealed::{NamespaceProbe, SealedProbe, UsernsGrant};
5318
5319 for grant in UsernsGrant::ALL {
5320 let probe = SealedProbe {
5321 userns_grant: *grant,
5322 namespace: NamespaceProbe::Ok,
5323 };
5324 let expected = if grant.permits_userns() {
5325 EnforcementTier::KernelSealed
5326 } else {
5327 EnforcementTier::KernelFull
5328 };
5329 assert_eq!(
5330 tier_from_probe(true, Some(true), probe),
5331 expected,
5332 "grant {grant:?} must decide the tier only through permits_userns()"
5333 );
5334 }
5335 }
5336
5337 #[test]
5338 fn tier_from_probe_non_linux_is_always_environment_only() {
5339 for landlock in [None, Some(true), Some(false)] {
5340 for sealed in [sealed_capable(), sealed_incapable()] {
5341 assert_eq!(
5342 tier_from_probe(false, landlock, sealed),
5343 EnforcementTier::EnvironmentOnly
5344 );
5345 }
5346 }
5347 }
5348
5349 #[test]
5350 fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
5351 assert_eq!(
5352 tier_from_probe(true, Some(true), sealed_incapable()),
5353 EnforcementTier::KernelFull
5354 );
5355 }
5356
5357 #[test]
5358 fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
5359 assert_eq!(
5360 tier_from_probe(true, Some(false), sealed_incapable()),
5361 EnforcementTier::KernelSeccompOnly
5362 );
5363 }
5364
5365 #[test]
5366 fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
5367 assert_eq!(
5368 tier_from_probe(true, None, sealed_incapable()),
5369 EnforcementTier::KernelSeccompOnly
5370 );
5371 }
5372
5373 /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a usable
5374 /// Landlock ABI, AppArmor out of the way, and a namespace probe that succeeded — and drops to
5375 /// exactly the tier the missing element still supports, never further.
5376 #[test]
5377 fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
5378 use crate::sealed::{NamespaceProbe, SealedProbe};
5379
5380 assert_eq!(
5381 tier_from_probe(true, Some(true), sealed_capable()),
5382 EnforcementTier::KernelSealed
5383 );
5384
5385 // No AppArmor profile (and the restriction is on) → the mechanism is unavailable, but
5386 // Landlock still is: `scoped`, not `advisory`.
5387 assert_eq!(
5388 tier_from_probe(
5389 true,
5390 Some(true),
5391 SealedProbe {
5392 userns_grant: crate::sealed::UsernsGrant::Withheld,
5393 namespace: NamespaceProbe::Ok
5394 }
5395 ),
5396 EnforcementTier::KernelFull
5397 );
5398
5399 // The container case, and its three other namespace failure modes.
5400 for namespace in [
5401 NamespaceProbe::Denied,
5402 NamespaceProbe::MapDenied,
5403 NamespaceProbe::MountDenied,
5404 NamespaceProbe::Unsupported,
5405 ] {
5406 assert_eq!(
5407 tier_from_probe(
5408 true,
5409 Some(true),
5410 SealedProbe {
5411 userns_grant: crate::sealed::UsernsGrant::ProfileConfining,
5412 namespace
5413 }
5414 ),
5415 EnforcementTier::KernelFull,
5416 "namespace probe {namespace:?} must fall back to KernelFull, never below it"
5417 );
5418 }
5419
5420 // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
5421 // cannot reach sealed no matter how good its namespaces are.
5422 assert_eq!(
5423 tier_from_probe(true, Some(false), sealed_capable()),
5424 EnforcementTier::KernelSeccompOnly
5425 );
5426 assert_eq!(
5427 tier_from_probe(true, None, sealed_capable()),
5428 EnforcementTier::KernelSeccompOnly
5429 );
5430
5431 // Not Linux at all.
5432 assert_eq!(
5433 tier_from_probe(false, Some(true), sealed_capable()),
5434 EnforcementTier::EnvironmentOnly
5435 );
5436 }
5437
5438 /// The ruleset ABI is the kernel's, capped at v3, on the two Landlock tiers only.
5439 #[test]
5440 fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
5441 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
5442 for (kernel, built) in [
5443 (Some(1), Some(1)),
5444 (Some(2), Some(2)),
5445 (Some(3), Some(3)),
5446 (Some(8), Some(3)),
5447 (Some(u8::MAX), Some(3)),
5448 ] {
5449 assert_eq!(
5450 ruleset_landlock_abi(kernel, tier),
5451 built,
5452 "kernel ABI {kernel:?} on {tier:?}"
5453 );
5454 }
5455 }
5456 }
5457
5458 /// No Landlock on the kernel, or a tier that installs no Landlock domain, builds at no ABI.
5459 #[test]
5460 fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
5461 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
5462 assert_eq!(ruleset_landlock_abi(None, tier), None);
5463 assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
5464 }
5465 for tier in [
5466 EnforcementTier::KernelSeccompOnly,
5467 EnforcementTier::EnvironmentOnly,
5468 ] {
5469 for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
5470 assert_eq!(
5471 ruleset_landlock_abi(kernel, tier),
5472 None,
5473 "kernel ABI {kernel:?} on {tier:?}"
5474 );
5475 }
5476 }
5477 }
5478
5479 /// `ShellEnforcement::resolve` carries the ruleset ABI of the probe it is handed, keyed on the
5480 /// applied tier, and an all-negative probe carries none.
5481 #[test]
5482 fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
5483 let negative = HostProbe::from_parts(None, crate::sealed::SealedProbe::default());
5484 for abi in [None, Some(1), Some(8)] {
5485 let enforcement = ShellEnforcement::resolve(
5486 &CapabilityPolicy::default(),
5487 murmur_artifact::ContainmentClass::Advisory,
5488 negative.with_landlock_kernel_abi(abi),
5489 )
5490 .unwrap();
5491 assert_eq!(
5492 enforcement.landlock_abi,
5493 ruleset_landlock_abi(abi, enforcement.tier),
5494 "kernel ABI {abi:?}"
5495 );
5496 // An all-negative probe resolves below the Landlock tiers on every OS.
5497 assert_eq!(enforcement.landlock_abi, None);
5498 }
5499 if cfg!(target_os = "linux") {
5500 let full = HostProbe::from_parts(Some(true), crate::sealed::SealedProbe::default());
5501 let enforcement = ShellEnforcement::resolve(
5502 &CapabilityPolicy::default(),
5503 murmur_artifact::ContainmentClass::Scoped,
5504 full.with_landlock_kernel_abi(Some(8)),
5505 )
5506 .unwrap();
5507 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
5508 assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
5509 }
5510 }
5511
5512 /// The `mur doctor` Landlock block for each kernel ABI that changes what it says.
5513 #[test]
5514 fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
5515 if !cfg!(target_os = "linux") {
5516 for abi in [None, Some(1), Some(8)] {
5517 assert_eq!(
5518 landlock_doctor_lines(abi),
5519 vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
5520 );
5521 }
5522 return;
5523 }
5524
5525 let none = landlock_doctor_lines(None).join("\n");
5526 assert!(none.contains("no Landlock on this host"), "{none}");
5527 assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
5528
5529 let v1 = landlock_doctor_lines(Some(1)).join("\n");
5530 assert!(v1.contains("kernel ABI: 1"), "{v1}");
5531 assert!(v1.contains("ruleset ABI: 1"), "{v1}");
5532 assert!(v1.contains("EXDEV"), "{v1}");
5533 assert!(v1.contains("Invalid cross-device link"), "{v1}");
5534 assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
5535 assert!(v1.contains("uv"), "{v1}");
5536
5537 let v2 = landlock_doctor_lines(Some(2)).join("\n");
5538 assert!(v2.contains("kernel ABI: 2"), "{v2}");
5539 assert!(v2.contains("ruleset ABI: 2"), "{v2}");
5540 assert!(!v2.contains("EXDEV"), "{v2}");
5541 assert!(
5542 v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
5543 "{v2}"
5544 );
5545
5546 for kernel in [3, 8] {
5547 let lines = landlock_doctor_lines(Some(kernel));
5548 assert_eq!(
5549 lines,
5550 vec![
5551 format!("kernel ABI: {kernel}"),
5552 "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
5553 ]
5554 );
5555 }
5556 }
5557
5558 /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
5559 /// could give it more. Silently installing a composed root under a `scoped` declaration would
5560 /// delete host paths its `interpreter_runtime` grants legitimately name.
5561 #[test]
5562 fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
5563 use murmur_artifact::ContainmentClass;
5564
5565 assert_eq!(
5566 applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
5567 EnforcementTier::KernelSealed
5568 );
5569 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
5570 assert_eq!(
5571 applied_tier(EnforcementTier::KernelSealed, declared),
5572 EnforcementTier::KernelFull,
5573 "declaring {declared} must not opt a capsule into the composed root"
5574 );
5575 }
5576
5577 // Every other tier is passed through untouched, whatever was declared — the declared floor
5578 // can only ever weaken the applied mechanism, never strengthen it past the host.
5579 for host_tier in [
5580 EnforcementTier::KernelFull,
5581 EnforcementTier::KernelSeccompOnly,
5582 EnforcementTier::EnvironmentOnly,
5583 ] {
5584 for declared in [
5585 ContainmentClass::Advisory,
5586 ContainmentClass::Scoped,
5587 ContainmentClass::Sealed,
5588 ] {
5589 assert_eq!(applied_tier(host_tier, declared), host_tier);
5590 }
5591 }
5592 }
5593
5594 #[test]
5595 fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
5596 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
5597
5598 let policy = CapabilityPolicy {
5599 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
5600 binary: "python3".to_string(),
5601 dirs: vec![
5602 // Already inside /usr, which the fixed list binds wholesale.
5603 InterpreterRuntimeDir {
5604 path: "/usr/lib/python3.11".to_string(),
5605 list_dir: true,
5606 },
5607 InterpreterRuntimeDir {
5608 path: "/opt/py/lib".to_string(),
5609 list_dir: true,
5610 },
5611 // A duplicate, and a relative path that names nothing absolute.
5612 InterpreterRuntimeDir {
5613 path: "/opt/py/lib".to_string(),
5614 list_dir: false,
5615 },
5616 InterpreterRuntimeDir {
5617 path: "relative/lib".to_string(),
5618 list_dir: false,
5619 },
5620 ],
5621 }],
5622 ..CapabilityPolicy::default()
5623 };
5624 let exec_allow = vec![
5625 PathBuf::from("/usr/bin/python3"),
5626 PathBuf::from("/opt/toolchain/bin/cc"),
5627 ];
5628
5629 assert_eq!(
5630 resolve_sealed_bind_dirs(&exec_allow, &policy),
5631 vec![
5632 PathBuf::from("/opt/toolchain/bin"),
5633 PathBuf::from("/opt/py/lib")
5634 ]
5635 );
5636 }
5637
5638 /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
5639 /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
5640 /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
5641 /// relative path — which cannot be re-based under the composed root at all — is skipped.
5642 #[test]
5643 fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
5644 use murmur_artifact::StagedRuntimeGrant;
5645
5646 let grant = |binary: &str, source: &str| StagedRuntimeGrant {
5647 binary: binary.to_string(),
5648 source_path: source.to_string(),
5649 pin: "pin-1".to_string(),
5650 };
5651 let policy = CapabilityPolicy {
5652 shell_staged_runtime: vec![
5653 grant("python3", "/opt/py"),
5654 // Inside /usr, which the fixed list already binds. Kept anyway — unlike
5655 // `resolve_sealed_bind_dirs`, which drops exactly this case.
5656 grant("perl", "/usr/lib/perl5"),
5657 // A duplicate of the first, and a relative path naming nothing absolute.
5658 grant("python3.12", "/opt/py"),
5659 grant("ruby", "relative/ruby"),
5660 ],
5661 ..CapabilityPolicy::default()
5662 };
5663
5664 assert_eq!(
5665 resolve_staged_runtime_dirs(&policy),
5666 vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
5667 );
5668 }
5669
5670 /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
5671 /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
5672 /// must therefore cover the same directories.
5673 #[test]
5674 fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
5675 use murmur_artifact::StagedRuntimeGrant;
5676
5677 let policy = CapabilityPolicy {
5678 shell_staged_runtime: vec![StagedRuntimeGrant {
5679 binary: "python3".to_string(),
5680 source_path: "/opt/py".to_string(),
5681 pin: "pin-1".to_string(),
5682 }],
5683 ..CapabilityPolicy::default()
5684 };
5685
5686 let dirs = resolve_staged_runtime_dirs(&policy);
5687 let grants = resolve_staged_runtime_landlock_grants(&policy);
5688
5689 assert_eq!(
5690 grants,
5691 vec![LandlockGrant {
5692 path: PathBuf::from("/opt/py"),
5693 list_dir: true,
5694 executable: true,
5695 pinned: None,
5696 }],
5697 "a staged tree is walked by the runtime that uses it, so it must be listable",
5698 );
5699 assert_eq!(
5700 grants
5701 .iter()
5702 .map(|grant| grant.path.clone())
5703 .collect::<Vec<_>>(),
5704 dirs,
5705 "the bound set and the granted set must not drift apart",
5706 );
5707 }
5708
5709 #[test]
5710 fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
5711 let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
5712
5713 assert_eq!(
5714 grants,
5715 crate::sealed::SEALED_RUNTIME_PATHS
5716 .iter()
5717 .map(|path| LandlockGrant {
5718 path: PathBuf::from(path),
5719 list_dir: true,
5720 executable: false,
5721 pinned: None,
5722 })
5723 .collect::<Vec<_>>(),
5724 "the composed root binds exactly these, so exactly these must be enumerable — \
5725 the bound set and the granted set must not drift apart",
5726 );
5727 assert!(
5728 grants.iter().all(|grant| grant.list_dir),
5729 "a runtime tree an interpreter walks is useless without getdents64",
5730 );
5731 assert!(
5732 grants.iter().all(|grant| !grant.executable),
5733 "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
5734 sealed capsule run every binary the host ships, whatever shell.allow says",
5735 );
5736 }
5737
5738 #[test]
5739 fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
5740 for tier in [
5741 EnforcementTier::KernelFull,
5742 EnforcementTier::KernelSeccompOnly,
5743 EnforcementTier::EnvironmentOnly,
5744 ] {
5745 assert!(
5746 resolve_sealed_runtime_landlock_grants(tier).is_empty(),
5747 "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
5748 would enumerate the real host filesystem",
5749 );
5750 }
5751 }
5752
5753 /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
5754 /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
5755 /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
5756 /// is the only honest thing a test can do about a kernel capability it may not have.
5757 #[test]
5758 fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
5759 let policy = CapabilityPolicy::default();
5760 let usr = PathBuf::from("/usr");
5761
5762 let scoped = ShellEnforcement::resolve(
5763 &policy,
5764 murmur_artifact::ContainmentClass::Scoped,
5765 HostProbe::probe(),
5766 )
5767 .expect("an empty policy resolves");
5768 assert!(
5769 !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
5770 "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
5771 unenumerable there",
5772 );
5773
5774 let sealed = ShellEnforcement::resolve(
5775 &policy,
5776 murmur_artifact::ContainmentClass::Sealed,
5777 HostProbe::probe(),
5778 )
5779 .expect("an empty policy resolves");
5780 let granted: Vec<&LandlockGrant> = sealed
5781 .landlock_grants
5782 .iter()
5783 .filter(|grant| {
5784 crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
5785 })
5786 .collect();
5787 if sealed.tier == EnforcementTier::KernelSealed {
5788 assert_eq!(
5789 granted.len(),
5790 crate::sealed::SEALED_RUNTIME_PATHS.len(),
5791 "a sealed session must carry one listable grant per bound runtime path",
5792 );
5793 assert!(granted
5794 .iter()
5795 .all(|grant| grant.list_dir && !grant.executable));
5796 } else {
5797 assert!(
5798 granted.is_empty(),
5799 "this host fell back to {:?}, where there is no composed root to enumerate",
5800 sealed.tier,
5801 );
5802 }
5803 }
5804
5805 #[test]
5806 fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
5807 let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
5808
5809 assert_eq!(
5810 grants,
5811 crate::sealed::SEALED_ETC_PATHS
5812 .iter()
5813 .map(|entry| LandlockGrant {
5814 path: PathBuf::from(entry.path),
5815 list_dir: entry.directory,
5816 executable: false,
5817 pinned: None,
5818 })
5819 .collect::<Vec<_>>(),
5820 "the composed root binds exactly these /etc entries, so exactly these must be \
5821 readable — the bound set and the granted set must not drift apart",
5822 );
5823 assert!(
5824 grants.iter().all(|grant| !grant.executable),
5825 "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
5826 run — /etc/alternatives is a directory of symlinks into /usr/bin",
5827 );
5828
5829 // The `directory` classification itself, spelled out independently of the constant so a
5830 // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
5831 // regular file) shows up as a test failure rather than as a TLS bug on a real host.
5832 let listable: Vec<&str> = grants
5833 .iter()
5834 .filter(|grant| grant.list_dir)
5835 .map(|grant| grant.path.to_str().unwrap())
5836 .collect();
5837 assert_eq!(
5838 listable,
5839 vec![
5840 "/etc/ld.so.conf.d",
5841 "/etc/alternatives",
5842 "/etc/ssl",
5843 "/etc/pki",
5844 "/etc/ca-certificates",
5845 "/etc/terminfo",
5846 ],
5847 "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
5848 the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
5849 );
5850 }
5851
5852 #[test]
5853 fn sealed_etc_landlock_grants_are_sealed_tier_only() {
5854 for tier in [
5855 EnforcementTier::KernelFull,
5856 EnforcementTier::KernelSeccompOnly,
5857 EnforcementTier::EnvironmentOnly,
5858 ] {
5859 assert!(
5860 resolve_sealed_etc_landlock_grants(tier).is_empty(),
5861 "{tier:?} has no composed root, so these rules would hand out the real host's \
5862 /etc — its trust store, resolv.conf and account databases",
5863 );
5864 }
5865 }
5866
5867 /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
5868 /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
5869 /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
5870 /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
5871 #[test]
5872 fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
5873 let policy = CapabilityPolicy::default();
5874 let is_etc_grant = |grant: &LandlockGrant| {
5875 crate::sealed::SEALED_ETC_PATHS
5876 .iter()
5877 .any(|entry| Path::new(entry.path) == grant.path)
5878 };
5879
5880 let scoped = ShellEnforcement::resolve(
5881 &policy,
5882 murmur_artifact::ContainmentClass::Scoped,
5883 HostProbe::probe(),
5884 )
5885 .expect("an empty policy resolves");
5886 assert!(
5887 !scoped.landlock_grants.iter().any(is_etc_grant),
5888 "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
5889 neighbours must not become readable there",
5890 );
5891
5892 let sealed = ShellEnforcement::resolve(
5893 &policy,
5894 murmur_artifact::ContainmentClass::Sealed,
5895 HostProbe::probe(),
5896 )
5897 .expect("an empty policy resolves");
5898 let granted: Vec<&LandlockGrant> = sealed
5899 .landlock_grants
5900 .iter()
5901 .filter(|grant| is_etc_grant(grant))
5902 .collect();
5903 if sealed.tier == EnforcementTier::KernelSealed {
5904 assert_eq!(
5905 granted.len(),
5906 crate::sealed::SEALED_ETC_PATHS.len(),
5907 "a sealed session must carry one read grant per bound /etc entry",
5908 );
5909 assert!(granted.iter().all(|grant| !grant.executable));
5910 } else {
5911 assert!(
5912 granted.is_empty(),
5913 "this host fell back to {:?}, where there is no composed root and /etc is the \
5914 host's own",
5915 sealed.tier,
5916 );
5917 }
5918 }
5919
5920 #[test]
5921 fn resolve_network_allowlist_ips_empty_list_is_empty() {
5922 let ips = resolve_network_allowlist_ips(&[]).unwrap();
5923 assert!(ips.is_empty());
5924 }
5925
5926 #[test]
5927 fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
5928 let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
5929 assert!(
5930 !ips.is_empty(),
5931 "localhost should resolve to at least one address"
5932 );
5933 assert!(
5934 ips.iter().all(|ip| ip.is_loopback()),
5935 "every resolved localhost address should be loopback, got {ips:?}"
5936 );
5937 }
5938
5939 #[test]
5940 fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
5941 let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
5942 assert!(!error.is_empty());
5943 }
5944
5945 /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
5946 /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
5947 /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
5948 /// to resolve, which keeps this test independent of the host's DNS.
5949 #[test]
5950 fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
5951 let ips =
5952 resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
5953 .expect("an unresolvable host must not be a hard error");
5954 assert!(
5955 ips.is_empty(),
5956 "an unresolvable host should contribute no IPs, got {ips:?}"
5957 );
5958 }
5959
5960 /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
5961 /// loop continues rather than aborting on the first miss.
5962 #[test]
5963 fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
5964 let ips = resolve_network_allowlist_ips(&[
5965 "definitely-not-a-real-host.invalid".to_string(),
5966 "localhost".to_string(),
5967 ])
5968 .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
5969
5970 assert!(
5971 !ips.is_empty(),
5972 "localhost's addresses should survive alongside an unresolvable host"
5973 );
5974 assert!(
5975 ips.iter().all(|ip| ip.is_loopback()),
5976 "only localhost should have contributed addresses, got {ips:?}"
5977 );
5978 }
5979
5980 #[test]
5981 fn resolve_network_allowlist_ips_dedupes_across_hosts() {
5982 // Two entries that both resolve to loopback should not produce duplicate IPs beyond
5983 // what's actually distinct.
5984 let ips =
5985 resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
5986 .unwrap();
5987 let mut sorted = ips.clone();
5988 sorted.sort();
5989 sorted.dedup();
5990 assert_eq!(
5991 ips.len(),
5992 sorted.len(),
5993 "resolved IPs should already be deduplicated"
5994 );
5995 }
5996
5997 #[test]
5998 fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
5999 let enforcement = ShellEnforcement::environment_only();
6000 assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
6001 assert!(enforcement.network_allow_ips.is_empty());
6002 }
6003
6004 /// With no scope a spawn's `RLIMIT_NPROC` is headroom over a real count. A `0` baseline
6005 /// would turn `max_processes` into an absolute per-uid ceiling that an interactive account
6006 /// is already past, failing the subprocess's first `fork()`.
6007 #[cfg(any(target_os = "linux", target_os = "macos"))]
6008 #[test]
6009 fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
6010 match ShellEnforcement::environment_only().nproc_bound() {
6011 crate::resources::NprocBound::Headroom { baseline } => assert!(
6012 baseline > 0,
6013 "the test process is owned by this uid, so the count cannot be zero"
6014 ),
6015 other => panic!("no scope must give headroom, got {other:?}"),
6016 }
6017 }
6018
6019 /// The baseline is taken on each call, not when the enforcement was built: threads the host
6020 /// starts in between are counted by the next spawn.
6021 #[cfg(target_os = "linux")]
6022 #[test]
6023 fn nproc_bound_without_a_scope_is_measured_on_each_call() {
6024 let _lock = crate::resources::test_support::host_threads_lock();
6025 let enforcement = ShellEnforcement::environment_only();
6026 let baseline = |bound| match bound {
6027 crate::resources::NprocBound::Headroom { baseline } => baseline,
6028 other => panic!("no scope must give headroom, got {other:?}"),
6029 };
6030
6031 let first = baseline(enforcement.nproc_bound());
6032 let holders = crate::resources::test_support::HeldThreads::start(200);
6033 let second = baseline(enforcement.nproc_bound());
6034 drop(holders);
6035
6036 assert!(
6037 second > first,
6038 "200 threads started between the calls must raise the baseline: {first} then {second}"
6039 );
6040 }
6041
6042 /// A spawn that will join a live scope leaves `RLIMIT_NPROC` to `pids.max`.
6043 #[cfg(target_os = "linux")]
6044 #[test]
6045 fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
6046 if !crate::cgroup::cgroup_delegation_available() {
6047 crate::runtime_err!(
6048 "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
6049 cannot delegate a cgroup v2 scope"
6050 );
6051 return;
6052 }
6053 let temp = tempfile::tempdir().unwrap();
6054 let limits = crate::resources::HostResourceLimits::default();
6055 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
6056 .expect("this host delegates a cgroup scope")
6057 .scope;
6058 let enforcement = ShellEnforcement::environment_only().with_host_bounding(scope, None);
6059
6060 assert_eq!(
6061 enforcement.nproc_bound(),
6062 crate::resources::NprocBound::CgroupPidsMax
6063 );
6064 }
6065
6066 /// A Linux capsule with a cgroup scope gets no `RLIMIT_NPROC` from the runtime, so threads the
6067 /// rest of the host starts after the enforcement was resolved cannot spend its fork headroom.
6068 /// The 400 holders put the uid well past any `RLIMIT_NPROC` derived from a count taken before
6069 /// they started, which fails the first `fork()` in the subprocess with `EAGAIN`. The 40
6070 /// concurrent `sleep`s and the concurrent `node` children each need headroom well past the
6071 /// declared `max_processes` of 32.
6072 #[cfg(target_os = "linux")]
6073 #[test]
6074 fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
6075 const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
6076 if !crate::cgroup::cgroup_delegation_available() {
6077 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
6078 return;
6079 }
6080 let _lock = crate::resources::test_support::host_threads_lock();
6081
6082 let temp = tempfile::tempdir().unwrap();
6083 let limits = crate::resources::HostResourceLimits {
6084 max_processes: 32,
6085 max_open_files: 256,
6086 ..crate::resources::HostResourceLimits::default()
6087 };
6088 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
6089 .expect("this host delegates a cgroup scope")
6090 .scope;
6091 assert!(
6092 scope.is_some(),
6093 "a required scope on Linux is always created"
6094 );
6095 let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
6096 let mut shell_allow = vec!["bash".to_string()];
6097 if node_available {
6098 shell_allow.push("node".to_string());
6099 }
6100 let policy = CapabilityPolicy {
6101 shell_allow,
6102 resources: limits,
6103 ..CapabilityPolicy::default()
6104 };
6105 let mut enforcement = ShellEnforcement::environment_only();
6106 enforcement.resource_limits = limits;
6107 let enforcement = enforcement.with_host_bounding(scope, None);
6108
6109 let holders = crate::resources::test_support::HeldThreads::start(400);
6110
6111 let result = crate::shell::execute_shell(
6112 "bash",
6113 &[
6114 "-c",
6115 "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
6116 ],
6117 &[],
6118 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
6119 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
6120 &policy,
6121 &enforcement,
6122 )
6123 .expect("bash must run");
6124 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
6125 assert_eq!(result.resource_limit_hit, None);
6126 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
6127 let lines: Vec<&str> = result.stdout.lines().collect();
6128 assert_eq!(
6129 lines,
6130 [
6131 crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
6132 "256"
6133 ],
6134 "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
6135 );
6136
6137 if node_available {
6138 let script = "const { spawn } = require('child_process');\n\
6139 const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
6140 const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
6141 child.once('error', (error) => resolve(error.message));\n\
6142 child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
6143 }));\n\
6144 Promise.all(runs).then((outcomes) => {\n\
6145 const failures = outcomes.filter((outcome) => outcome !== null);\n\
6146 if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
6147 });\n";
6148 let result = crate::shell::execute_shell(
6149 "node",
6150 &["-e", script],
6151 &[],
6152 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
6153 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
6154 &policy,
6155 &enforcement,
6156 )
6157 .expect("node must run");
6158 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
6159 } else {
6160 crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
6161 }
6162
6163 drop(holders);
6164 }
6165
6166 /// Without a scope `RLIMIT_NPROC` is the only process bound, so its baseline has to follow the
6167 /// host: 300 threads started after the enforcement was resolved raise the next spawn's
6168 /// ceiling, and releasing them lowers the one after.
6169 #[cfg(target_os = "linux")]
6170 #[test]
6171 fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
6172 const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
6173 let _lock = crate::resources::test_support::host_threads_lock();
6174
6175 let temp = tempfile::tempdir().unwrap();
6176 let policy = CapabilityPolicy {
6177 shell_allow: vec!["bash".to_string()],
6178 resources: crate::resources::HostResourceLimits {
6179 max_processes: 64,
6180 ..crate::resources::HostResourceLimits::default()
6181 },
6182 ..CapabilityPolicy::default()
6183 };
6184 let mut enforcement = ShellEnforcement::environment_only();
6185 enforcement.resource_limits = policy.resources;
6186
6187 let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
6188 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
6189 if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
6190 crate::runtime_err!(
6191 "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
6192 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
6193 );
6194 return;
6195 }
6196
6197 let hard_nproc = || -> u64 {
6198 let result = crate::shell::execute_shell(
6199 "bash",
6200 &["-c", "ulimit -Hu"],
6201 &[],
6202 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
6203 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
6204 &policy,
6205 &enforcement,
6206 )
6207 .expect("bash must run");
6208 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
6209 result.stdout.trim().parse().expect("a finite ceiling")
6210 };
6211
6212 let holders = crate::resources::test_support::HeldThreads::start(300);
6213 let held = hard_nproc();
6214 assert!(
6215 held >= before + 64 + 150,
6216 "the ceiling must count the holder threads started after the enforcement was \
6217 resolved: {held} with {before} tasks before them"
6218 );
6219
6220 drop(holders);
6221 let released = hard_nproc();
6222 assert!(
6223 released < held,
6224 "the ceiling must drop once the holders exit: {released} after, {held} while held"
6225 );
6226 }
6227
6228 fn make_executable(path: &Path, contents: &str) {
6229 use std::os::unix::fs::PermissionsExt;
6230 std::fs::write(path, contents).unwrap();
6231 let mut perms = std::fs::metadata(path).unwrap().permissions();
6232 perms.set_mode(0o755);
6233 std::fs::set_permissions(path, perms).unwrap();
6234 }
6235
6236 fn bootstrap_log_contents(workdir: &Path) -> String {
6237 std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
6238 }
6239
6240 // ---- exec allowlist resolution (identity, not names) ----
6241
6242 #[test]
6243 fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
6244 let temp = tempfile::tempdir().unwrap();
6245 let bin_dir = temp.path().join("bin");
6246 std::fs::create_dir(&bin_dir).unwrap();
6247 let tool = bin_dir.join("mytool");
6248 make_executable(&tool, "#!/bin/sh\nexit 0\n");
6249
6250 let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
6251 assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
6252 }
6253
6254 #[test]
6255 fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
6256 let temp = tempfile::tempdir().unwrap();
6257 let resolved = resolve_exec_allowlist_in(
6258 &["definitely-not-a-real-binary".to_string(), String::new()],
6259 &[temp.path().to_path_buf()],
6260 );
6261 assert!(
6262 resolved.is_empty(),
6263 "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
6264 );
6265 }
6266
6267 #[test]
6268 fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
6269 let temp = tempfile::tempdir().unwrap();
6270 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
6271 let resolved =
6272 resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
6273 assert!(resolved.is_empty());
6274 }
6275
6276 #[test]
6277 fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
6278 let temp = tempfile::tempdir().unwrap();
6279 let real = temp.path().join("real-tool");
6280 make_executable(&real, "#!/bin/sh\nexit 0\n");
6281 let bin_dir = temp.path().join("bin");
6282 std::fs::create_dir(&bin_dir).unwrap();
6283 std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
6284
6285 let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
6286 assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
6287 }
6288
6289 // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
6290
6291 #[test]
6292 fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
6293 let temp = tempfile::tempdir().unwrap();
6294 let bin_dir = temp.path().join("bin");
6295 std::fs::create_dir(&bin_dir).unwrap();
6296 let tool = bin_dir.join("mytool");
6297 make_executable(&tool, "#!/bin/sh\nexit 0\n");
6298
6299 let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
6300 assert_eq!(
6301 resolved,
6302 std::fs::canonicalize(&tool)
6303 .unwrap()
6304 .to_string_lossy()
6305 .into_owned(),
6306 "a resolvable name must report the canonical absolute path, not the bare name"
6307 );
6308 }
6309
6310 #[test]
6311 fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
6312 let temp = tempfile::tempdir().unwrap();
6313 let path_dirs = [temp.path().to_path_buf()];
6314 let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
6315 assert_eq!(
6316 resolved, "definitely-not-a-real-binary",
6317 "an unresolvable name must fall back to the bare name, never error"
6318 );
6319 assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
6320 }
6321
6322 #[test]
6323 fn resolve_invoked_binary_path_takes_the_first_path_match() {
6324 let temp = tempfile::tempdir().unwrap();
6325 let first = temp.path().join("first");
6326 let second = temp.path().join("second");
6327 std::fs::create_dir(&first).unwrap();
6328 std::fs::create_dir(&second).unwrap();
6329 make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
6330 make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
6331
6332 let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
6333 assert_eq!(
6334 resolved,
6335 std::fs::canonicalize(first.join("mytool"))
6336 .unwrap()
6337 .to_string_lossy()
6338 .into_owned(),
6339 "reporting must match execvp: the first PATH hit is the one that ran"
6340 );
6341 }
6342
6343 #[test]
6344 fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
6345 let temp = tempfile::tempdir().unwrap();
6346 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
6347 assert_eq!(
6348 resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
6349 "mytool"
6350 );
6351 }
6352
6353 #[test]
6354 fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
6355 let temp = tempfile::tempdir().unwrap();
6356 let real = temp.path().join("real-tool");
6357 make_executable(&real, "#!/bin/sh\nexit 0\n");
6358 let link = temp.path().join("link-tool");
6359 std::os::unix::fs::symlink(&real, &link).unwrap();
6360
6361 assert_eq!(
6362 resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
6363 std::fs::canonicalize(&real)
6364 .unwrap()
6365 .to_string_lossy()
6366 .into_owned()
6367 );
6368 }
6369
6370 // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
6371
6372 /// Builds a minimal but well-formed ELF64 image exercising exactly the fields
6373 /// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
6374 /// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
6375 /// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
6376 fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6377 let has_dynamic = !needed.is_empty() || runpath.is_some();
6378
6379 let mut phnum: u16 = 1; // PT_LOAD
6380 if interp.is_some() {
6381 phnum += 1;
6382 }
6383 if has_dynamic {
6384 phnum += 1;
6385 }
6386
6387 let ehsize = 64usize;
6388 let phentsize = 56usize;
6389 let phoff = ehsize;
6390 let body_start = phoff + phentsize * phnum as usize;
6391
6392 // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
6393 let mut strtab: Vec<u8> = vec![0];
6394 let mut needed_offsets = Vec::new();
6395 for name in needed {
6396 needed_offsets.push(strtab.len() as u64);
6397 strtab.extend_from_slice(name.as_bytes());
6398 strtab.push(0);
6399 }
6400 let runpath_offset = runpath.map(|r| {
6401 let off = strtab.len() as u64;
6402 strtab.extend_from_slice(r.as_bytes());
6403 strtab.push(0);
6404 off
6405 });
6406
6407 let mut body: Vec<u8> = Vec::new();
6408
6409 let interp_offset = body_start + body.len();
6410 if let Some(i) = interp {
6411 body.extend_from_slice(i.as_bytes());
6412 body.push(0);
6413 }
6414
6415 // Align the dynamic array to 8 bytes.
6416 while !(body_start + body.len()).is_multiple_of(8) {
6417 body.push(0);
6418 }
6419 let dyn_offset = body_start + body.len();
6420 let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
6421 let dyn_size = num_dyn * 16;
6422 let strtab_offset = dyn_offset + dyn_size;
6423
6424 if has_dynamic {
6425 let mut push_dyn = |tag: u64, val: u64| {
6426 body.extend_from_slice(&tag.to_le_bytes());
6427 body.extend_from_slice(&val.to_le_bytes());
6428 };
6429 for &o in &needed_offsets {
6430 push_dyn(1, o); // DT_NEEDED
6431 }
6432 if let Some(ro) = runpath_offset {
6433 push_dyn(29, ro); // DT_RUNPATH
6434 }
6435 push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
6436 push_dyn(10, strtab.len() as u64); // DT_STRSZ
6437 push_dyn(0, 0); // DT_NULL
6438 assert_eq!(body_start + body.len(), strtab_offset);
6439 body.extend_from_slice(&strtab);
6440 }
6441
6442 let total_len = body_start + body.len();
6443 let mut out = vec![0u8; total_len];
6444 out[0..4].copy_from_slice(b"\x7fELF");
6445 out[4] = 2; // ELFCLASS64
6446 out[5] = 1; // little-endian
6447 out[6] = 1; // EI_VERSION
6448 out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
6449 out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
6450 out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
6451 out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
6452 out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
6453 out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
6454
6455 let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
6456 phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
6457 if let Some(i) = interp {
6458 phdrs.push((
6459 3,
6460 interp_offset as u64,
6461 interp_offset as u64,
6462 i.len() as u64 + 1,
6463 ));
6464 }
6465 if has_dynamic {
6466 phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
6467 }
6468 for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
6469 let base = phoff + idx * phentsize;
6470 out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
6471 out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
6472 out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
6473 out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
6474 }
6475
6476 out[body_start..total_len].copy_from_slice(&body);
6477 out
6478 }
6479
6480 #[test]
6481 fn parse_elf_extracts_interp_needed_and_runpath() {
6482 let bytes = build_elf64(
6483 Some("/lib64/ld-linux-x86-64.so.2"),
6484 &["libc.so.6", "libm.so.6"],
6485 Some("$ORIGIN/../lib:/opt/custom/lib"),
6486 );
6487 let deps = parse_elf_dependencies(&bytes).expect("well-formed ELF64 must parse");
6488 assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
6489 assert_eq!(
6490 deps.needed,
6491 vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
6492 );
6493 assert_eq!(
6494 deps.runpaths,
6495 vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
6496 "DT_RUNPATH must be split on ':' with empty parts dropped"
6497 );
6498 }
6499
6500 #[test]
6501 fn parse_elf_static_binary_contributes_only_itself() {
6502 // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
6503 let bytes = build_elf64(None, &[], None);
6504 let deps = parse_elf_dependencies(&bytes).expect("a static ELF64 is still valid ELF");
6505 assert_eq!(deps, ElfDependencies::default());
6506 }
6507
6508 #[test]
6509 fn parse_elf_rejects_non_elf_and_non_elf64() {
6510 assert!(parse_elf_dependencies(b"not an elf at all, way too short").is_none());
6511 // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
6512 let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
6513 elf32[4] = 1;
6514 assert!(
6515 parse_elf_dependencies(&elf32).is_none(),
6516 "32-bit ELF is not a binary this runtime execs and must not parse"
6517 );
6518 }
6519
6520 // ---- soname resolution (loader search-order, synthetic fixtures) ----
6521
6522 #[test]
6523 fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
6524 let temp = tempfile::tempdir().unwrap();
6525 let origin = temp.path().join("app");
6526 std::fs::create_dir(&origin).unwrap();
6527 let libs = temp.path().join("libs");
6528 std::fs::create_dir(&libs).unwrap();
6529 std::fs::write(libs.join("libc.so.6"), "x").unwrap();
6530
6531 // Found in a standard search directory.
6532 assert_eq!(
6533 resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
6534 Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
6535 );
6536 // Unresolvable soname → None (shrink-not-fail).
6537 assert_eq!(
6538 resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
6539 None
6540 );
6541
6542 // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
6543 let origin_lib = origin.join("lib");
6544 std::fs::create_dir(&origin_lib).unwrap();
6545 std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
6546 assert_eq!(
6547 resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
6548 Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
6549 );
6550 }
6551
6552 // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
6553
6554 #[test]
6555 fn resolve_landlock_grants_derives_interp_and_needed_closure() {
6556 let temp = tempfile::tempdir().unwrap();
6557 let bin_dir = temp.path().join("bin");
6558 let lib_dir = temp.path().join("lib");
6559 std::fs::create_dir(&bin_dir).unwrap();
6560 std::fs::create_dir(&lib_dir).unwrap();
6561
6562 // A fake interpreter and a fake shared library, both outside any "workdir".
6563 let interp = bin_dir.join("ld-fake.so");
6564 std::fs::write(&interp, "not really elf").unwrap();
6565 std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
6566
6567 // A synthetic dynamically-linked binary naming that interp + that soname.
6568 let prog = bin_dir.join("prog");
6569 let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
6570 std::fs::write(&prog, &elf).unwrap();
6571
6572 let grants =
6573 resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
6574
6575 let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
6576 assert!(
6577 grants.contains(&expect(prog)),
6578 "the binary itself must be granted: {grants:?}"
6579 );
6580 assert!(
6581 grants.contains(&expect(interp)),
6582 "the ELF interpreter must be granted: {grants:?}"
6583 );
6584 assert!(
6585 grants.contains(&expect(lib_dir.join("libfake.so.1"))),
6586 "each resolved DT_NEEDED library must be granted: {grants:?}"
6587 );
6588 }
6589
6590 #[test]
6591 fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
6592 let temp = tempfile::tempdir().unwrap();
6593 let bin_dir = temp.path().join("bin");
6594 std::fs::create_dir(&bin_dir).unwrap();
6595
6596 // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
6597 let prog = bin_dir.join("prog");
6598 std::fs::write(
6599 &prog,
6600 build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
6601 )
6602 .unwrap();
6603 let missing = bin_dir.join("does-not-exist");
6604
6605 let grants =
6606 resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
6607
6608 // Only the real binary survives; the missing binary, missing interp, and unresolvable
6609 // soname each contribute nothing (shrink-not-fail).
6610 assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
6611 }
6612
6613 // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
6614
6615 fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
6616 InterpreterRuntimeGrant {
6617 binary: binary.to_string(),
6618 dirs: dirs
6619 .iter()
6620 .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
6621 path: path.to_string(),
6622 list_dir: *list_dir,
6623 })
6624 .collect(),
6625 }
6626 }
6627
6628 #[test]
6629 fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
6630 // Two directories under the same binary, opposite `list_dir` — proving enumerability is
6631 // per-directory and copied exactly, not inferred or unified.
6632 let grants = resolve_interpreter_runtime_grants(&[interp_grant(
6633 "python3",
6634 &[
6635 ("/usr/lib/python3.11", true),
6636 ("/usr/lib/python3.11/lib-dynload", false),
6637 ],
6638 )]);
6639
6640 assert_eq!(
6641 grants,
6642 vec![
6643 LandlockGrant {
6644 path: PathBuf::from("/usr/lib/python3.11"),
6645 list_dir: true,
6646 executable: true,
6647 pinned: None,
6648 },
6649 LandlockGrant {
6650 path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
6651 list_dir: false,
6652 executable: true,
6653 pinned: None,
6654 },
6655 ]
6656 );
6657 }
6658
6659 #[test]
6660 fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
6661 // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
6662 // scope` skips any that fail to open), and multiple grants flatten in order.
6663 let grants = resolve_interpreter_runtime_grants(&[
6664 interp_grant("python3", &[("/opt/py/nonexistent", false)]),
6665 interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
6666 ]);
6667
6668 assert_eq!(
6669 grants,
6670 vec![
6671 LandlockGrant {
6672 path: PathBuf::from("/opt/py/nonexistent"),
6673 list_dir: false,
6674 executable: true,
6675 pinned: None,
6676 },
6677 LandlockGrant {
6678 path: PathBuf::from("/opt/rb/also-nonexistent"),
6679 list_dir: true,
6680 executable: true,
6681 pinned: None,
6682 },
6683 ]
6684 );
6685 assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
6686 }
6687
6688 #[test]
6689 fn non_listable_files_marks_every_closure_file_non_enumerable() {
6690 // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
6691 // (ReadDir on a regular file is a no-op anyway).
6692 let wrapped = LandlockGrant::non_listable_files(vec![
6693 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
6694 PathBuf::from("/usr/bin/bash"),
6695 ]);
6696 assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
6697 assert_eq!(
6698 wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
6699 vec![
6700 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
6701 PathBuf::from("/usr/bin/bash"),
6702 ]
6703 );
6704 }
6705
6706 // ---- native tool entrypoints ------------------------------------------------------------
6707
6708 fn write_entrypoint(dir: &Path, name: &str, head: &[u8]) -> PathBuf {
6709 let path = dir.join(name);
6710 std::fs::write(&path, head).unwrap();
6711 path
6712 }
6713
6714 /// `path` as the runtime records a native tool it has just written there, inside `workdir`.
6715 fn installed(path: &Path, workdir: &Path) -> NativeEntrypoint {
6716 NativeEntrypoint::derive(path, &std::fs::read(path).unwrap(), workdir)
6717 }
6718
6719 #[test]
6720 fn shebang_interpreter_reads_the_absolute_interpreter_and_nothing_else() {
6721 let cases: &[(&[u8], Option<&str>)] = &[
6722 (b"#!/bin/sh\necho hi\n", Some("/bin/sh")),
6723 (b"#! /usr/bin/python3 -u\n", Some("/usr/bin/python3")),
6724 // `env` is what the kernel runs; `python3` is found by `env` and needs its own grant.
6725 (b"#!/usr/bin/env python3\n", Some("/usr/bin/env")),
6726 (b"#!sh\n", None),
6727 (b"#!\n", None),
6728 (b"\x7fELF\x02\x01\x01", None),
6729 (b"", None),
6730 ];
6731 for (head, expected) in cases {
6732 assert_eq!(
6733 shebang_interpreter(head),
6734 expected.map(PathBuf::from),
6735 "{:?}",
6736 String::from_utf8_lossy(head)
6737 );
6738 }
6739
6740 // A `#!` line longer than the kernel's own buffer names nothing past it.
6741 let mut long = b"#!".to_vec();
6742 long.extend(std::iter::repeat_n(b' ', SHEBANG_READ_LIMIT as usize));
6743 long.extend(b"/bin/sh\n");
6744 assert_eq!(shebang_interpreter(&long), None);
6745 }
6746
6747 /// The entrypoint, pinned to its inode, and its interpreter are added as `Execute + ReadFile`
6748 /// file grants, and nothing is taken away from the enforcement they extend.
6749 #[test]
6750 fn for_native_entrypoint_adds_the_pinned_entrypoint_and_its_interpreter_only() {
6751 let dir = tempfile::tempdir().unwrap();
6752 let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
6753 let existing = LandlockGrant {
6754 path: PathBuf::from("/usr/bin/git"),
6755 list_dir: false,
6756 executable: true,
6757 pinned: None,
6758 };
6759 let base = ShellEnforcement {
6760 tier: EnforcementTier::KernelFull,
6761 landlock_grants: vec![existing.clone()],
6762 ..ShellEnforcement::environment_only()
6763 };
6764
6765 let native = base.for_native_entrypoint(&installed(&entrypoint, dir.path()));
6766
6767 assert_eq!(native.landlock_grants[0], existing);
6768 let added: Vec<&LandlockGrant> = native.landlock_grants[1..].iter().collect();
6769 let identity = FileIdentity::of(&std::fs::metadata(&entrypoint).unwrap());
6770 assert!(
6771 added
6772 .iter()
6773 .any(|grant| grant.path == entrypoint && grant.pinned == Some(identity)),
6774 "the entrypoint must be granted, pinned to its inode: {added:?}"
6775 );
6776 let canonical_sh = std::fs::canonicalize("/bin/sh").unwrap();
6777 assert!(
6778 added.iter().any(|grant| grant.path == canonical_sh),
6779 "/bin/sh must be granted: {added:?}"
6780 );
6781 assert!(
6782 added
6783 .iter()
6784 .all(|grant| grant.executable && !grant.list_dir),
6785 "{added:?}"
6786 );
6787 // The closure is files, never a directory such as the workdir or /etc.
6788 assert!(added.iter().all(|grant| !grant.path.is_dir()), "{added:?}");
6789 assert!(native.sealed_bind_dirs.is_empty(), "not sealed: no binds");
6790 assert!(
6791 Arc::ptr_eq(&native.session_netns, &base.session_netns),
6792 "a native spawn joins the session's own namespace"
6793 );
6794 assert_eq!(
6795 base.landlock_grants.len(),
6796 1,
6797 "the session's grants are untouched"
6798 );
6799 }
6800
6801 /// On `KernelSealed`, an interpreter outside the fixed runtime paths has its directory bound
6802 /// into the composed root as an optional bind; one inside them adds nothing.
6803 #[test]
6804 fn for_native_entrypoint_binds_an_interpreter_outside_the_runtime_paths_when_sealed() {
6805 let dir = tempfile::tempdir().unwrap();
6806 let sealed = ShellEnforcement {
6807 tier: EnforcementTier::KernelSealed,
6808 ..ShellEnforcement::environment_only()
6809 };
6810
6811 let system = installed(
6812 &write_entrypoint(dir.path(), "system", b"#!/bin/sh\n"),
6813 dir.path(),
6814 );
6815 assert!(sealed
6816 .for_native_entrypoint(&system)
6817 .sealed_bind_dirs
6818 .is_empty());
6819
6820 let custom = installed(
6821 &write_entrypoint(dir.path(), "custom", b"#!/opt/tools/bin/run\n"),
6822 dir.path(),
6823 );
6824 assert_eq!(
6825 sealed.for_native_entrypoint(&custom).sealed_bind_dirs,
6826 vec![PathBuf::from("/opt/tools/bin")]
6827 );
6828
6829 let scoped = ShellEnforcement {
6830 tier: EnforcementTier::KernelFull,
6831 ..ShellEnforcement::environment_only()
6832 };
6833 assert!(scoped
6834 .for_native_entrypoint(&custom)
6835 .sealed_bind_dirs
6836 .is_empty());
6837 }
6838
6839 /// The capsule can rewrite the installed entrypoint between calls. Whatever it writes, the
6840 /// next spawn is granted exactly what the installed image was: a new `#!` interpreter, on any
6841 /// tier, adds no exec grant and no composed-root bind.
6842 #[test]
6843 fn a_rewritten_entrypoint_gains_no_grant() {
6844 let dir = tempfile::tempdir().unwrap();
6845 let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
6846 let native = installed(&entrypoint, dir.path());
6847
6848 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6849 let base = ShellEnforcement {
6850 tier,
6851 ..ShellEnforcement::environment_only()
6852 };
6853 let before = base.for_native_entrypoint(&native);
6854
6855 std::fs::write(&entrypoint, b"#!/opt/elsewhere/bin/run\n").unwrap();
6856 let after = base.for_native_entrypoint(&native);
6857 std::fs::write(&entrypoint, b"#!/bin/sh\ncat\n").unwrap();
6858
6859 assert_eq!(after.landlock_grants, before.landlock_grants, "{tier:?}");
6860 assert_eq!(after.sealed_bind_dirs, before.sealed_bind_dirs, "{tier:?}");
6861 assert!(
6862 after
6863 .landlock_grants
6864 .iter()
6865 .all(|grant| !grant.path.starts_with("/opt/elsewhere")),
6866 "{tier:?}: {:?}",
6867 after.landlock_grants
6868 );
6869 }
6870 }
6871
6872 /// The dependency walk neither grants nor reads a file under the accessible workdir, where the
6873 /// capsule could plant a library whose own entries widen the closure.
6874 #[test]
6875 fn the_entrypoint_closure_does_not_reach_into_the_workdir() {
6876 let temp = tempfile::tempdir().unwrap();
6877 let workdir = temp.path().join("workdir");
6878 let tool_dir = workdir.join("tools").join("tool");
6879 std::fs::create_dir_all(&tool_dir).unwrap();
6880 let system = temp.path().join("system");
6881 std::fs::create_dir(&system).unwrap();
6882 std::fs::write(system.join("libsys.so"), "x").unwrap();
6883 let outside = temp.path().join("outside");
6884 std::fs::create_dir(&outside).unwrap();
6885 std::fs::write(outside.join("libpicked.so"), "x").unwrap();
6886
6887 // Planted next to the entrypoint, found through `$ORIGIN`, and naming a file outside.
6888 std::fs::write(
6889 tool_dir.join("libplanted.so"),
6890 build_elf64(
6891 None,
6892 &[outside.join("libpicked.so").to_str().unwrap()],
6893 None,
6894 ),
6895 )
6896 .unwrap();
6897 // A symlink next to the entrypoint: canonicalising it lands outside the workdir, so only
6898 // refusing the `$ORIGIN` lookup itself keeps the host file out of the closure.
6899 std::fs::write(outside.join("host-binary"), "x").unwrap();
6900 std::os::unix::fs::symlink(outside.join("host-binary"), tool_dir.join("liblinked.so"))
6901 .unwrap();
6902 let image = build_elf64(
6903 None,
6904 &["libplanted.so", "liblinked.so", "libsys.so"],
6905 Some("$ORIGIN"),
6906 );
6907 let entrypoint = tool_dir.join("tool");
6908 std::fs::write(&entrypoint, &image).unwrap();
6909
6910 let native = NativeEntrypoint::derive_in(
6911 &entrypoint,
6912 &image,
6913 &workdir,
6914 std::slice::from_ref(&system),
6915 );
6916
6917 let sys = std::fs::canonicalize(system.join("libsys.so")).unwrap();
6918 assert_eq!(native.closure, vec![sys]);
6919 }
6920
6921 /// A pinned grant whose path no longer opens to the pinned inode is dropped: replacing the
6922 /// entrypoint, here with a symlink to a host binary, leaves it with no `Execute` rule.
6923 #[cfg(target_os = "linux")]
6924 #[test]
6925 fn a_replaced_entrypoint_loses_its_pinned_grant() {
6926 let accessible = tempfile::tempdir().unwrap();
6927 let session = accessible
6928 .path()
6929 .join(".murmur")
6930 .join("ses_native_entrypoint");
6931 std::fs::create_dir_all(&session).unwrap();
6932 let entrypoint = write_entrypoint(&session, "tool", b"#!/bin/sh\n");
6933 let native = installed(&entrypoint, accessible.path());
6934 let grants = ShellEnforcement {
6935 tier: EnforcementTier::KernelFull,
6936 ..ShellEnforcement::environment_only()
6937 }
6938 .for_native_entrypoint(&native)
6939 .landlock_grants;
6940 let open = |grants: &[LandlockGrant]| {
6941 linux_enforce::open_landlock_fds(
6942 accessible.path(),
6943 grants,
6944 &[],
6945 false,
6946 None,
6947 None,
6948 None,
6949 None,
6950 )
6951 .unwrap()
6952 .grant_count()
6953 };
6954 let installed_count = open(&grants);
6955 // Holds the installed inode for the rest of the test. Freed, its number may be handed to
6956 // the next file created on this filesystem, which would then match the pin.
6957 std::fs::hard_link(&entrypoint, session.join("installed")).unwrap();
6958
6959 std::fs::remove_file(&entrypoint).unwrap();
6960 std::os::unix::fs::symlink("/bin/sh", &entrypoint).unwrap();
6961 assert_eq!(open(&grants), installed_count - 1, "symlink swapped in");
6962
6963 std::fs::remove_file(&entrypoint).unwrap();
6964 std::fs::write(&entrypoint, b"#!/bin/sh\n").unwrap();
6965 assert_eq!(open(&grants), installed_count - 1, "new file, new inode");
6966 }
6967
6968 // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
6969 //
6970 // The canonical statement of what the *content* checks in this file are worth, referenced by
6971 // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
6972 // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
6973 // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
6974 // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
6975 // buys one thing: changing a constant cannot happen by accident.
6976 //
6977 // The enforcement claim for this constant is verified only by the manual procedure in
6978 // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
6979 // uncontainerized Linux hardware.
6980
6981 #[test]
6982 fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
6983 let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
6984 .iter()
6985 .map(|device| (device.path, device.writable))
6986 .collect();
6987 assert_eq!(
6988 listed,
6989 vec![
6990 ("/dev/null", true),
6991 ("/dev/zero", false),
6992 ("/dev/urandom", false),
6993 ],
6994 "the capsule device set is fixed at exactly these three paths and these access \
6995 levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
6996 and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
6997 both. Widening this list is allowed only on demonstrated workload failure, and the \
6998 failure gets recorded alongside the widening (see the manual acceptance procedure on \
6999 the diagnostics reference page)."
7000 );
7001 }
7002
7003 #[test]
7004 fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
7005 // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
7006 // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
7007 // shell child has dropped). It is excluded because nothing needs a blocking RNG when
7008 // /dev/urandom is granted — not because writing to it would poison the kernel pool.
7009 for excluded in [
7010 "/dev/random",
7011 "/dev/full",
7012 "/dev/tty",
7013 "/dev/console",
7014 "/dev/mem",
7015 "/dev/sda",
7016 "/dev",
7017 ] {
7018 assert!(
7019 !CAPSULE_DEVICE_GRANTS
7020 .iter()
7021 .any(|device| device.path == excluded),
7022 "{excluded} must not be in the capsule device set — it is denied by the ordinary \
7023 no-matching-rule path, and adding it here would be the only way to change that"
7024 );
7025 }
7026 }
7027
7028 #[test]
7029 fn capsule_device_grants_name_absolute_paths_under_dev() {
7030 // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
7031 // against whatever cwd the host process happens to have.
7032 for device in CAPSULE_DEVICE_GRANTS {
7033 assert!(
7034 device.path.starts_with("/dev/"),
7035 "{} must be an absolute path under /dev",
7036 device.path
7037 );
7038 }
7039 }
7040
7041 // ---- `denied_socket_domains` ----------------------------------------------------------
7042 //
7043 // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
7044 // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
7045 // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
7046 // real Linux hardware.
7047
7048 #[test]
7049 fn denied_socket_domains_denies_unix_by_default() {
7050 let denied = denied_socket_domains(false);
7051 assert!(
7052 denied.contains(&LINUX_AF_UNIX),
7053 "AF_UNIX must be denied when the manifest does not declare \
7054 capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
7055 );
7056 }
7057
7058 #[test]
7059 fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
7060 let denied = denied_socket_domains(true);
7061 assert!(
7062 !denied.contains(&LINUX_AF_UNIX),
7063 "a capsule that declared capabilities.network.unix_sockets: true must get no \
7064 AF_UNIX deny rule at all"
7065 );
7066 }
7067
7068 /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
7069 /// identical for both values of the one flag that exists.
7070 #[test]
7071 fn denied_socket_domains_always_denies_netlink_and_packet() {
7072 for unix_sockets_allowed in [false, true] {
7073 let denied = denied_socket_domains(unix_sockets_allowed);
7074 assert!(
7075 denied.contains(&LINUX_AF_NETLINK),
7076 "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
7077 {unix_sockets_allowed})"
7078 );
7079 assert!(
7080 denied.contains(&LINUX_AF_PACKET),
7081 "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
7082 {unix_sockets_allowed})"
7083 );
7084 }
7085 }
7086
7087 /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
7088 /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
7089 /// deny.
7090 #[test]
7091 fn denied_socket_domains_never_denies_ip_families() {
7092 for unix_sockets_allowed in [false, true] {
7093 let denied = denied_socket_domains(unix_sockets_allowed);
7094 assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
7095 assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
7096 }
7097 }
7098
7099 /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
7100 /// an unnoticed side effect of touching the function.
7101 #[test]
7102 fn denied_socket_domains_returns_exactly_the_documented_sets() {
7103 assert_eq!(
7104 denied_socket_domains(false),
7105 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
7106 );
7107 assert_eq!(
7108 denied_socket_domains(true),
7109 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
7110 );
7111 }
7112
7113 /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
7114 /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
7115 /// they really do agree with `libc`; on macOS this test compiles away, because
7116 /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
7117 /// constants are literals.
7118 #[cfg(target_os = "linux")]
7119 #[test]
7120 fn linux_socket_domain_constants_match_libc() {
7121 assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
7122 assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
7123 assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
7124 assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
7125 assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
7126 }
7127
7128 /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
7129 /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
7130 /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
7131 /// anything stated here.
7132 #[test]
7133 fn socket_domain_allow_and_deny_sets_never_overlap() {
7134 for unix_sockets_allowed in [false, true] {
7135 let denied = denied_socket_domains(unix_sockets_allowed);
7136 for domain in allowed_socket_domains(unix_sockets_allowed) {
7137 assert!(
7138 !denied.contains(&domain),
7139 "socket domain {domain} is both allowed and denied \
7140 (unix_sockets_allowed = {unix_sockets_allowed})"
7141 );
7142 }
7143 }
7144 }
7145
7146 /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
7147 /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
7148 /// default-deny filter would silently keep unix sockets refused even after a capsule declared
7149 /// the capability.
7150 #[test]
7151 fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
7152 assert_eq!(
7153 allowed_socket_domains(false),
7154 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
7155 );
7156 assert_eq!(
7157 allowed_socket_domains(true),
7158 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
7159 );
7160 }
7161
7162 // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
7163 //
7164 // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
7165 // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
7166 // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
7167 // the allowlist against a newer upstream profile.
7168
7169 #[test]
7170 fn allowlist_contains_no_syscall_that_must_stay_denied() {
7171 for name in SECCOMP_MUST_STAY_DENIED {
7172 assert!(
7173 !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
7174 "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
7175 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
7176 in the same edit and say why in the commit"
7177 );
7178 }
7179 }
7180
7181 /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
7182 /// so omitting them would refuse the child's own first `execve` and no capsule could run a
7183 /// shell tool at all. Exec is decided by the Landlock domain instead; see
7184 /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
7185 #[test]
7186 fn allowlist_permits_exec_because_landlock_now_decides_it() {
7187 for name in ["execve", "execveat"] {
7188 assert!(
7189 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
7190 "{name} must reach the kernel — the default action is a deny, so omitting it \
7191 refuses the child's own exec and nothing runs"
7192 );
7193 }
7194 }
7195
7196 /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
7197 /// for a syscall by discarding every conditional chain already recorded for it. A bare
7198 /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
7199 /// denials at filter-build time, with nothing at runtime to show for it.
7200 #[test]
7201 fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
7202 assert!(
7203 !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
7204 "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
7205 );
7206 }
7207
7208 /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
7209 /// which is exactly the state where one of them later gets removed and the other is missed.
7210 #[test]
7211 fn allowlist_has_no_duplicates() {
7212 let mut seen = std::collections::HashSet::new();
7213 for name in SECCOMP_SYSCALL_ALLOWLIST {
7214 assert!(seen.insert(*name), "{name} appears twice in the allowlist");
7215 }
7216 }
7217
7218 /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
7219 /// still "pass" every other test here, while denying every syscall a shell needs.
7220 #[test]
7221 fn allowlist_covers_the_syscalls_no_process_can_run_without() {
7222 for name in [
7223 "read",
7224 "write",
7225 "openat",
7226 "close",
7227 "mmap",
7228 "munmap",
7229 "brk",
7230 "exit_group",
7231 "futex",
7232 "rt_sigreturn",
7233 "clone",
7234 "wait4",
7235 "getpid",
7236 "getdents64",
7237 ] {
7238 assert!(
7239 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
7240 "{name} is missing from the allowlist — no process could run under this filter"
7241 );
7242 }
7243 }
7244
7245 #[test]
7246 fn network_ip_allowed_matches_only_the_resolved_allowlist() {
7247 let allow = vec![IpAddr::from([127, 0, 0, 1])];
7248 assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
7249 assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
7250 assert!(
7251 !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
7252 "an empty resolved allowlist must deny every destination"
7253 );
7254 }
7255
7256 // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
7257
7258 #[test]
7259 fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
7260 // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
7261 // namespace, and a silent tier would imply it has no documented limits. It warns when a
7262 // shell is declared, and stays silent only when there is no subprocess to enforce on.
7263 assert_eq!(
7264 tier_warning(EnforcementTier::KernelFull, false),
7265 Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
7266 );
7267 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
7268 }
7269
7270 #[test]
7271 fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
7272 assert_eq!(
7273 tier_warning(EnforcementTier::KernelSeccompOnly, false),
7274 Some((W_SEC_002, SECCOMP_ONLY_WARNING))
7275 );
7276 assert_eq!(
7277 tier_warning(EnforcementTier::EnvironmentOnly, false),
7278 Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
7279 );
7280 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
7281 assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
7282 assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
7283 }
7284
7285 /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
7286 /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
7287 /// same condition refuses the launch outright, so a warning would describe a session that
7288 /// does not exist — and never for a capsule with no process tree to bound.
7289 #[test]
7290 fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
7291 assert_eq!(
7292 aggregate_bounding_warning(false, true, false),
7293 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
7294 );
7295 assert_eq!(
7296 aggregate_bounding_warning(false, false, false),
7297 None,
7298 "a capsule that cannot spawn a subprocess has nothing to bound"
7299 );
7300 assert_eq!(
7301 aggregate_bounding_warning(true, true, false),
7302 None,
7303 "on Linux this case is a refused launch, not a warning"
7304 );
7305 assert_eq!(
7306 aggregate_bounding_warning(true, true, true),
7307 None,
7308 "a scope exists, so there is no gap"
7309 );
7310 }
7311
7312 /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
7313 /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
7314 /// would expect to stop a fork bomb does not.
7315 #[test]
7316 fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
7317 let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
7318 assert!(message.contains("per-uid"), "message was: {message}");
7319 assert!(message.contains("fork bomb"), "message was: {message}");
7320 assert!(message.contains("pids.max"), "message was: {message}");
7321 }
7322
7323 #[test]
7324 fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
7325 let temp = tempfile::tempdir().unwrap();
7326 warn_for_missing_aggregate_bounding(
7327 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7328 true,
7329 false,
7330 );
7331 let log = bootstrap_log_contents(temp.path());
7332
7333 if cfg!(target_os = "linux") {
7334 assert!(
7335 log.is_empty(),
7336 "Linux refuses the launch instead of warning: {log}"
7337 );
7338 } else {
7339 assert!(log.contains(W_SEC_010), "log was: {log}");
7340 assert!(
7341 log.contains(&security_warning_link(W_SEC_010)),
7342 "log was: {log}"
7343 );
7344 }
7345 }
7346
7347 fn warning_test_policy() -> CapabilityPolicy {
7348 CapabilityPolicy {
7349 shell_allow: vec!["bash".to_string()],
7350 network_allow: vec!["https://api.example.com".to_string()],
7351 ..CapabilityPolicy::default()
7352 }
7353 }
7354
7355 #[test]
7356 fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
7357 let temp = tempfile::tempdir().unwrap();
7358 warn_for_enforcement_tier(
7359 EnforcementTier::KernelFull,
7360 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7361 &warning_test_policy(),
7362 );
7363 let log = bootstrap_log_contents(temp.path());
7364 assert!(
7365 log.contains(W_SEC_005),
7366 "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
7367 );
7368 assert!(
7369 log.contains("denied, not absent"),
7370 "must state the scope's documented limit: paths outside the workdir are denied, not \
7371 absent: {log}"
7372 );
7373 assert!(
7374 log.contains("read+execute") && log.contains("outside the workdir"),
7375 "must describe what is now actually granted (narrow, derived read+execute outside \
7376 the workdir), not an unfixed grant bug: {log}"
7377 );
7378 assert!(
7379 log.contains(&security_warning_link(W_SEC_005)),
7380 "must link to the diagnostics doc page: {log}"
7381 );
7382 }
7383
7384 #[test]
7385 fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
7386 let temp = tempfile::tempdir().unwrap();
7387 warn_for_enforcement_tier(
7388 EnforcementTier::KernelFull,
7389 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7390 &CapabilityPolicy::default(),
7391 );
7392 assert_eq!(
7393 bootstrap_log_contents(temp.path()),
7394 "",
7395 "with no shell declared there is no subprocess to enforce on — stay silent"
7396 );
7397 }
7398
7399 #[test]
7400 fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
7401 let temp = tempfile::tempdir().unwrap();
7402 warn_for_enforcement_tier(
7403 EnforcementTier::KernelSeccompOnly,
7404 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7405 &warning_test_policy(),
7406 );
7407 let log = bootstrap_log_contents(temp.path());
7408 assert!(
7409 log.contains("Landlock"),
7410 "must name the missing primitive: {log}"
7411 );
7412 assert!(
7413 log.contains("filesystem"),
7414 "must name what is not enforced: {log}"
7415 );
7416 assert!(
7417 log.contains(W_SEC_002),
7418 "must carry its warning code: {log}"
7419 );
7420 assert!(
7421 log.contains(&security_warning_link(W_SEC_002)),
7422 "must link to the diagnostics doc page: {log}"
7423 );
7424 }
7425
7426 #[test]
7427 fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
7428 let temp = tempfile::tempdir().unwrap();
7429 warn_for_enforcement_tier(
7430 EnforcementTier::EnvironmentOnly,
7431 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7432 &warning_test_policy(),
7433 );
7434 let log = bootstrap_log_contents(temp.path());
7435 assert!(
7436 log.contains("permanent"),
7437 "must state the tier is permanent, not pending: {log}"
7438 );
7439 assert!(
7440 log.contains("bash"),
7441 "the still-accurate bash/network bypass warning must also fire: {log}"
7442 );
7443 assert!(
7444 log.contains(W_SEC_001),
7445 "must carry the tier warning code: {log}"
7446 );
7447 assert!(
7448 log.contains(W_SEC_003),
7449 "must carry the bash/network bypass warning code: {log}"
7450 );
7451 }
7452
7453 // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
7454
7455 struct ForcePrepareFailureGuard;
7456
7457 impl ForcePrepareFailureGuard {
7458 fn new() -> Self {
7459 FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
7460 Self
7461 }
7462 }
7463
7464 impl Drop for ForcePrepareFailureGuard {
7465 fn drop(&mut self) {
7466 FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
7467 }
7468 }
7469
7470 #[test]
7471 fn execute_shell_fails_closed_when_enforcement_setup_fails() {
7472 let temp = tempfile::tempdir().unwrap();
7473 let policy = CapabilityPolicy {
7474 shell_allow: vec!["bash".to_string()],
7475 ..CapabilityPolicy::default()
7476 };
7477 let marker = temp.path().join("spawned-anyway.marker");
7478 let script = format!("echo ran > '{}'", marker.display());
7479
7480 {
7481 let _guard = ForcePrepareFailureGuard::new();
7482 let error = crate::shell::execute_shell(
7483 "bash",
7484 &["-c", &script],
7485 &[],
7486 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7487 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7488 &policy,
7489 &ShellEnforcement::environment_only(),
7490 )
7491 .unwrap_err();
7492 assert!(
7493 error.to_string().contains("sandbox"),
7494 "the error must name what failed to initialize: {error}"
7495 );
7496 assert!(
7497 !marker.exists(),
7498 "no subprocess may be spawned at all when enforcement setup fails"
7499 );
7500 }
7501
7502 // Control run without the forced failure: the identical call spawns and writes the
7503 // marker — proving the marker really would have caught a spawn above.
7504 let result = crate::shell::execute_shell(
7505 "bash",
7506 &["-c", &script],
7507 &[],
7508 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7509 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7510 &policy,
7511 &ShellEnforcement::environment_only(),
7512 )
7513 .unwrap();
7514 assert_eq!(result.exit_code, 0);
7515 assert!(marker.exists());
7516 }
7517}
7518
7519#[cfg(target_os = "linux")]
7520#[cfg(test)]
7521#[allow(clippy::print_stdout, clippy::print_stderr)]
7522mod landlock_probe_isolation {
7523 /// The Landlock probe must leave the calling process exactly as it found it.
7524 ///
7525 /// Stated as "the sealed probe gives the same answer before and after" rather than by
7526 /// inspecting the process's Landlock domain, because there is no API to ask whether a task
7527 /// is landlocked — and because that framing is what actually broke: the probe restricted
7528 /// itself, and every later attempt to build a composed root was refused, on every host, in
7529 /// a way that looked like the host's fault.
7530 ///
7531 /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
7532 /// halves report the same failure and this passes trivially; on a capable one, an
7533 /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
7534 #[test]
7535 fn the_landlock_probe_does_not_restrict_the_calling_process() {
7536 let before = crate::sealed::probe_sealed_support().namespace;
7537 let _ = super::linux_enforce::probe_landlock_full_access();
7538 let after = crate::sealed::probe_sealed_support().namespace;
7539
7540 assert_eq!(
7541 before, after,
7542 "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
7543 it has restricted the calling process instead of a forked child, which permanently \
7544 forbids the mount family to this process and everything it spawns",
7545 );
7546 }
7547}
7548
7549#[cfg(all(test, target_os = "linux"))]
7550#[allow(clippy::print_stdout, clippy::print_stderr)]
7551mod linux_integration_tests {
7552 use std::path::Path;
7553
7554 use super::*;
7555
7556 /// Spread-base supplying the three *host-bounding* fields (`resource_limits`,
7557 /// `cgroup_scope`, `workdir_guard`) to the enforcement literals below, which spell out the
7558 /// policy half by hand because they need to pin `tier` explicitly.
7559 ///
7560 /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
7561 /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
7562 /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
7563 /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
7564 /// of the child `pre_exec` runs on every tier), and no cgroup scope or workdir guard, so each
7565 /// spawn gets `RLIMIT_NPROC` headroom over the uid's count at that spawn. The `tier` it
7566 /// carries is always overridden by the literal that spreads it; only the host-bounding tail
7567 /// is ever consumed from here.
7568 fn host_bounding_base() -> ShellEnforcement {
7569 // Every session holds a runtime directory, and a sealed one cannot compose a root
7570 // without it. Kept past the test: a clone may outlive the value.
7571 let base = tempfile::tempdir().unwrap().keep();
7572 ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
7573 }
7574
7575 /// `<accessible>/.murmur/<session id>/`, the session workdir a `--workdir` launch stages
7576 /// into — the shape that makes the two workdirs different paths, which is the only shape in
7577 /// which putting a staging directory in the wrong one is visible.
7578 fn session_workdir_under(accessible: &Path) -> PathBuf {
7579 let session = accessible.join(".murmur").join("ses_sandbox_tests");
7580 std::fs::create_dir_all(&session).unwrap();
7581 session
7582 }
7583
7584 /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
7585 /// It pins *which* rights of [`LANDLOCK_TARGET_ABI`] `apply_landlock_scope` hands the workdir,
7586 /// so re-adding device-node creation, or losing the move and truncate rights, becomes a test
7587 /// failure instead of a silent regression.
7588 ///
7589 /// It proves **nothing** about whether the kernel enforces that set — that is the manual
7590 /// acceptance procedure in
7591 /// `docs/content/reference/workdir-device-node-manual-verification.md` and the
7592 /// `kernel_tier_*` tests below, not this test.
7593 /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
7594 /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
7595 #[test]
7596 fn workdir_landlock_grant_withholds_device_and_socket_creation() {
7597 use landlock::{Access, AccessFs, ABI};
7598
7599 assert_eq!(
7600 LANDLOCK_TARGET_ABI, 3,
7601 "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
7602 );
7603 let all_v3 = AccessFs::from_all(ABI::V3);
7604 // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
7605 // unconditionally-withheld rights below are checked against the permissive case rather
7606 // than passing trivially because `Execute` happened to be absent too.
7607 let granted = linux_enforce::workdir_access_rights(true);
7608
7609 for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
7610 assert!(
7611 all_v3.contains(withheld),
7612 "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
7613 test's premise (and the workdir grant's comment) needs revisiting"
7614 );
7615 assert!(
7616 !granted.contains(withheld),
7617 "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
7618 by handle_access, withholding it here is what makes it denied rather than merely \
7619 un-granted"
7620 );
7621 }
7622
7623 for added in [AccessFs::Refer, AccessFs::Truncate] {
7624 assert!(
7625 granted.contains(added),
7626 "the workdir grant must carry {added:?}: without Refer a move between workdir \
7627 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
7628 capsule's own files"
7629 );
7630 }
7631
7632 assert!(
7633 granted.contains(AccessFs::MakeFifo),
7634 "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
7635 );
7636
7637 let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
7638 assert_eq!(
7639 granted, expected,
7640 "the workdir_exec grant must be exactly Landlock ABI v3 minus \
7641 MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
7642 this test and the constant's comment together"
7643 );
7644 }
7645
7646 /// The workdir right set as a ruleset built at ABI v1 sees it, after `apply_landlock_scope`
7647 /// masks it to the handled set: exactly ABI v1 minus the three `Make*` rights, with neither
7648 /// `Refer` nor `Truncate`, so a 5.13–5.18 kernel is handed no right it does not know.
7649 #[test]
7650 fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
7651 use landlock::{Access, AccessFs, ABI};
7652
7653 let all_v1 = AccessFs::from_all(ABI::V1);
7654 let masked = linux_enforce::workdir_access_rights(true) & all_v1;
7655
7656 assert_eq!(
7657 masked,
7658 all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
7659 "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
7660 );
7661 assert!(!masked.contains(AccessFs::Refer));
7662 assert!(!masked.contains(AccessFs::Truncate));
7663 assert_eq!(
7664 linux_enforce::workdir_access_rights(false) & all_v1,
7665 masked & !AccessFs::Execute,
7666 "masking must not move the workdir_exec axis"
7667 );
7668 }
7669
7670 /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
7671 /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
7672 /// procedure in
7673 /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
7674 /// regression that matters here: flipping the default back — which would silently reopen the
7675 /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
7676 #[test]
7677 fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
7678 use landlock::AccessFs;
7679
7680 assert!(
7681 !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
7682 "the default must withhold Execute from the workdir — that withholding is the whole \
7683 of what enforces capabilities.shell.allow now that the exec supervisor is gone"
7684 );
7685 assert!(
7686 linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
7687 "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
7688 compile-and-run workflows it exists for silently stop working"
7689 );
7690
7691 // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
7692 assert_eq!(
7693 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
7694 linux_enforce::workdir_access_rights(false),
7695 "workdir_exec must be a one-bit axis — no other right may ride along with it"
7696 );
7697 }
7698
7699 /// A sealed enforcement over a fresh runtime directory beside `accessible`.
7700 fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
7701 ShellEnforcement {
7702 tier: EnforcementTier::KernelSealed,
7703 ..ShellEnforcement::environment_only()
7704 }
7705 .with_runtime_dir(test_runtime_dir(base), None)
7706 }
7707
7708 /// The tier gate on the workdir-backed `/tmp` fd and the runtime directory's fd, in both
7709 /// directions, against real directories. Opening an fd is an ordinary `open()` — no Landlock
7710 /// call, no namespace, no privilege — so this runs on any Linux host, unlike the enforcement
7711 /// it feeds.
7712 ///
7713 /// It proves **nothing** about whether the kernel then honors the rule built from that fd; that
7714 /// is the hand-run procedure in
7715 /// `docs/content/reference/sealed-containment-manual-verification.md` (step "3d — writability").
7716 /// It catches two things: that each fd is there to build a rule from at all, and that the
7717 /// directory backing `/tmp` exists before `build_sealed_root` binds it.
7718 #[test]
7719 fn sealed_tmp_store_and_runtime_dir_are_opened_for_landlock_on_the_sealed_tier_only() {
7720 let accessible = tempfile::tempdir().unwrap();
7721 let runtime = tempfile::tempdir().unwrap();
7722 let session = session_workdir_under(accessible.path());
7723 let session_root = crate::workdir_fs::WorkdirRoot::open(&session).unwrap();
7724 let enforcement = sealed_with_runtime_dir(runtime.path());
7725 let sources = SealedSourceFds::open(&session_root, &enforcement)
7726 .expect("a real, writable workdir must yield the sealed sources");
7727 let fds = linux_enforce::open_landlock_fds(
7728 accessible.path(),
7729 &[],
7730 &[],
7731 false,
7732 None,
7733 Some(&sources),
7734 Some(Arc::clone(&sources.runtime_dir)),
7735 sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
7736 )
7737 .expect("a real, writable workdir must yield sealed Landlock fds");
7738
7739 assert!(
7740 fds.sealed_tmp_fd().is_some(),
7741 "the composed root binds {} at /tmp read-write on every sealed capsule, so its fd must \
7742 always be opened — a missing rule leaves /tmp mounted but EACCES",
7743 crate::sealed::SEALED_TMP_DIR_NAME,
7744 );
7745 assert!(fds.runtime_dir_fd().is_some());
7746 assert!(
7747 fds.records_dir_fd().is_some(),
7748 "the records directory takes the runtime directory's read-only rule"
7749 );
7750 assert!(
7751 session.join(crate::sealed::SEALED_TMP_DIR_NAME).is_dir(),
7752 "the /tmp store must be created before build_sealed_root binds it",
7753 );
7754 assert!(
7755 !accessible
7756 .path()
7757 .join(crate::sealed::SEALED_TMP_DIR_NAME)
7758 .exists(),
7759 "enabling sealed must add nothing to the top level of the accessible workdir — that \
7760 directory is the capsule's deliverable",
7761 );
7762
7763 for tier in [
7764 EnforcementTier::KernelFull,
7765 EnforcementTier::KernelSeccompOnly,
7766 EnforcementTier::EnvironmentOnly,
7767 ] {
7768 let accessible = tempfile::tempdir().unwrap();
7769 let session = session_workdir_under(accessible.path());
7770 let fds = linux_enforce::open_landlock_fds(
7771 accessible.path(),
7772 &[],
7773 &[],
7774 false,
7775 None,
7776 None,
7777 None,
7778 None,
7779 )
7780 .expect("a real, writable workdir must yield Landlock fds on any tier");
7781
7782 assert!(
7783 fds.sealed_tmp_fd().is_none(),
7784 "{tier:?} composes no root and binds nothing at /tmp, so granting a workdir-backed \
7785 /tmp would grant a path this tier's capsule never sees",
7786 );
7787 assert!(
7788 !session.join(crate::sealed::SEALED_TMP_DIR_NAME).exists(),
7789 "{tier:?} must not even create the store — an unbound directory in the session \
7790 workdir is litter the capsule can see",
7791 );
7792 }
7793 }
7794
7795 /// The `.mur-tmp` descriptor is taken once, with no link followed: a link planted after the
7796 /// store is created and before the descriptor is taken is refused, and one swapped in after
7797 /// the descriptor is taken redirects nothing reached through it — the Landlock rule's fd, and
7798 /// the identity the bind is verified against.
7799 #[test]
7800 fn sealed_tmp_store_descriptor_is_taken_once_and_no_link_moves_it() {
7801 use std::os::fd::AsRawFd;
7802
7803 // Planted between creation and the descriptor: refused, nothing written outside.
7804 let (_dir, session, outside, root) = sealed_planted();
7805 let tmp = session.join(crate::sealed::SEALED_TMP_DIR_NAME);
7806 let link_target = outside.clone();
7807 BEFORE_TMP_STORE_FD.with(|slot| {
7808 *slot.borrow_mut() = Some(Box::new(move || {
7809 std::fs::remove_dir(&tmp).unwrap();
7810 std::os::unix::fs::symlink(&link_target, &tmp).unwrap();
7811 }));
7812 });
7813 let error = open_sealed_tmp_store(&root).unwrap_err();
7814 assert!(
7815 error.contains(crate::sealed::SEALED_TMP_DIR_NAME),
7816 "{error}"
7817 );
7818 assert_outside_untouched(&outside);
7819 assert_eq!(root.take_refusals().len(), 1);
7820
7821 // Swapped in after the descriptor is taken: the descriptor still names the store.
7822 let (_dir, session, outside, root) = sealed_planted();
7823 let tmp = session.join(crate::sealed::SEALED_TMP_DIR_NAME);
7824 let fd = open_sealed_tmp_store(&root).unwrap();
7825 let moved = session.join("store-moved");
7826 std::fs::rename(&tmp, &moved).unwrap();
7827 std::os::unix::fs::symlink(&outside, &tmp).unwrap();
7828 let source = PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()));
7829 std::fs::write(source.join("probe"), b"x").unwrap();
7830 assert_eq!(std::fs::read(moved.join("probe")).unwrap(), b"x");
7831 assert_outside_untouched(&outside);
7832 }
7833
7834 /// The synthetic account databases, written into `sealed-etc/` beside the runtime directory
7835 /// and opened by the same parent-side call.
7836 ///
7837 /// Like the `/tmp` test above it proves nothing about kernel enforcement — that is the hand-run
7838 /// in `docs/content/reference/sealed-containment-manual-verification.md`. It catches that the
7839 /// files exist before `build_sealed_root` binds them, that there is an fd to build each rule
7840 /// from, and that nothing is written into either workdir for them.
7841 #[test]
7842 fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
7843 let accessible = tempfile::tempdir().unwrap();
7844 let runtime = tempfile::tempdir().unwrap();
7845 let session = session_workdir_under(accessible.path());
7846 let session_root = crate::workdir_fs::WorkdirRoot::open(&session).unwrap();
7847 let enforcement = sealed_with_runtime_dir(runtime.path());
7848 let sources = SealedSourceFds::open(&session_root, &enforcement).unwrap();
7849 let fds = linux_enforce::open_landlock_fds(
7850 accessible.path(),
7851 &[],
7852 &[],
7853 false,
7854 None,
7855 Some(&sources),
7856 None,
7857 None,
7858 )
7859 .expect("a real, writable workdir must yield sealed Landlock fds");
7860
7861 let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
7862 .iter()
7863 .filter(|entry| entry.synthetic.is_some())
7864 .collect();
7865 assert_eq!(
7866 fds.sealed_identity_fd_count(),
7867 synthetic.len(),
7868 "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
7869 );
7870 let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
7871 for entry in &synthetic {
7872 let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
7873 assert!(
7874 path.is_file(),
7875 "{} must be written into sealed-etc/",
7876 entry.path
7877 );
7878 }
7879 for dir in [accessible.path(), session.as_path()] {
7880 assert!(
7881 !dir.join(".mur-etc").exists(),
7882 "{} gained .mur-etc",
7883 dir.display()
7884 );
7885 }
7886
7887 let fds = linux_enforce::open_landlock_fds(
7888 accessible.path(),
7889 &[],
7890 &[],
7891 false,
7892 None,
7893 None,
7894 None,
7895 None,
7896 )
7897 .unwrap();
7898 assert_eq!(fds.sealed_identity_fd_count(), 0);
7899 }
7900
7901 /// A sealed root cannot be composed without the session's runtime directory: the refusal is
7902 /// the parent's, before anything is spawned.
7903 #[test]
7904 fn a_sealed_root_needs_the_runtime_directory() {
7905 let accessible = tempfile::tempdir().unwrap();
7906 let session = session_workdir_under(accessible.path());
7907 let enforcement = ShellEnforcement {
7908 tier: EnforcementTier::KernelSealed,
7909 ..ShellEnforcement::environment_only()
7910 };
7911 let error = SealedSourceFds::open(
7912 &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
7913 &enforcement,
7914 )
7915 .err()
7916 .expect("refused");
7917 assert!(error.contains("runtime directory"), "{error}");
7918 }
7919
7920 /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
7921 ///
7922 /// Both are derived from `shell::SYNTHETIC_HOME_DIR_NAME`, and this asserts the end result
7923 /// rather than the constant — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and
7924 /// `os.environ["HOME"]`, and a disagreement between them is silent until a tool picks the
7925 /// wrong one. The uid/gid are checked against the workdir's own owner, which is where
7926 /// `ensure_sealed_identity_files` reads the capsule's identity from.
7927 #[test]
7928 fn the_synthetic_passwd_entry_carries_exactly_the_home_the_subprocess_env_gets() {
7929 use std::os::unix::fs::MetadataExt;
7930
7931 let accessible = tempfile::tempdir().unwrap();
7932 let session = session_workdir_under(accessible.path());
7933 let runtime = tempfile::tempdir().unwrap();
7934 let roots = test_runtime_dir(runtime.path());
7935 let written = ensure_sealed_identity_files(
7936 &roots.sealed_etc,
7937 &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
7938 )
7939 .unwrap();
7940 let env = crate::shell::build_shell_env(
7941 &crate::types::CapabilityPolicy::default(),
7942 &[],
7943 &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
7944 None,
7945 None,
7946 )
7947 .unwrap();
7948
7949 let passwd = std::fs::read_to_string(&written[0]).unwrap();
7950 let owner = std::fs::metadata(&session).unwrap();
7951 let entry: Vec<&str> = passwd
7952 .lines()
7953 .last()
7954 .expect("the capsule's own entry is the last line")
7955 .split(':')
7956 .collect();
7957
7958 assert_eq!(entry[2], owner.uid().to_string(), "uid field");
7959 assert_eq!(entry[3], owner.gid().to_string(), "gid field");
7960 assert_eq!(
7961 Some(&entry[5].to_string()),
7962 env.get("HOME"),
7963 "pw_dir and $HOME are the same string or the entry is worse than useless",
7964 );
7965
7966 let group = std::fs::read_to_string(&written[1]).unwrap();
7967 assert_eq!(
7968 group.lines().last().unwrap().split(':').nth(2),
7969 Some(owner.gid().to_string().as_str()),
7970 "gid field of the capsule's own group",
7971 );
7972 }
7973
7974 /// A workdir root over `accessible`'s session directory beside an `outside/` directory holding
7975 /// one sentinel file.
7976 fn sealed_planted() -> (
7977 tempfile::TempDir,
7978 PathBuf,
7979 PathBuf,
7980 crate::workdir_fs::WorkdirRoot,
7981 ) {
7982 let dir = tempfile::tempdir().unwrap();
7983 let accessible = dir.path().join("work");
7984 let outside = dir.path().join("outside");
7985 std::fs::create_dir_all(&accessible).unwrap();
7986 std::fs::create_dir_all(&outside).unwrap();
7987 std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
7988 let root = crate::workdir_fs::WorkdirRoot::open(&accessible)
7989 .unwrap()
7990 .session_beneath("ses_sandbox_tests")
7991 .unwrap();
7992 (dir, root.path().to_path_buf(), outside, root)
7993 }
7994
7995 fn assert_outside_untouched(outside: &Path) {
7996 assert_eq!(
7997 std::fs::read(outside.join("sentinel.txt")).unwrap(),
7998 b"operator data"
7999 );
8000 assert_eq!(
8001 std::fs::read_dir(outside).unwrap().count(),
8002 1,
8003 "outside/ gained an entry"
8004 );
8005 }
8006
8007 /// `.mur-tmp` linked to an outside directory: the store's open fails naming it and nothing is
8008 /// written outside.
8009 #[test]
8010 fn workdir_path_refused_sealed_staging() {
8011 let tmp = crate::sealed::SEALED_TMP_DIR_NAME;
8012 let (_dir, session, outside, root) = sealed_planted();
8013 std::os::unix::fs::symlink(&outside, session.join(tmp)).unwrap();
8014
8015 let error = open_sealed_tmp_store(&root).unwrap_err();
8016
8017 assert!(error.contains(tmp), "{error}");
8018 assert_outside_untouched(&outside);
8019 let refusals = root.take_refusals();
8020 assert_eq!(refusals.len(), 1, "{refusals:?}");
8021 assert_eq!(
8022 refusals[0].refused_at,
8023 format!(".murmur/ses_sandbox_tests/{tmp}")
8024 );
8025 }
8026
8027 /// Staging twice rewrites `sealed-etc/passwd` in place: the inode a Landlock rule was bound to
8028 /// on the first call is the file the second call leaves.
8029 #[test]
8030 fn sealed_identity_files_keep_their_inode() {
8031 use std::os::unix::fs::MetadataExt;
8032
8033 let (_dir, _session, _outside, root) = sealed_planted();
8034 let runtime = tempfile::tempdir().unwrap();
8035 let roots = test_runtime_dir(runtime.path());
8036 let passwd = roots.sealed_etc.path().join("passwd");
8037 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
8038 let first = std::fs::metadata(&passwd).unwrap().ino();
8039 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
8040 assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
8041 assert!(root.take_refusals().is_empty());
8042 }
8043
8044 /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
8045 /// no fork, no spawn.
8046 ///
8047 /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
8048 /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
8049 /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
8050 /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
8051 /// manager creating build directories — and the `Execute` axis, which must track
8052 /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
8053 /// the same storage and a binary staged in one is the same bytes as a binary staged in the
8054 /// other.
8055 #[test]
8056 fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
8057 use landlock::AccessFs;
8058
8059 for workdir_exec in [false, true] {
8060 let rights = linux_enforce::workdir_access_rights(workdir_exec);
8061
8062 for needed in [
8063 AccessFs::WriteFile,
8064 AccessFs::ReadFile,
8065 AccessFs::ReadDir,
8066 AccessFs::MakeReg,
8067 AccessFs::MakeDir,
8068 AccessFs::RemoveFile,
8069 AccessFs::RemoveDir,
8070 ] {
8071 assert!(
8072 rights.contains(needed),
8073 "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
8074 `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
8075 );
8076 }
8077
8078 assert_eq!(
8079 rights.contains(AccessFs::Execute),
8080 workdir_exec,
8081 "/tmp is backed by a directory inside the workdir, so Execute there must follow \
8082 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
8083 make /tmp a hole through which capsule-written bytes run",
8084 );
8085 }
8086
8087 assert_eq!(
8088 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
8089 linux_enforce::workdir_access_rights(false),
8090 "the /tmp rule and the workdir rule share one right-set binding in \
8091 apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
8092 that binding must be revisited together",
8093 );
8094 }
8095
8096 #[test]
8097 fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
8098 let mut command = std::process::Command::new("true");
8099 let enforcement = ShellEnforcement::environment_only();
8100 let supervisor = prepare_enforcement(
8101 &mut command,
8102 &enforcement,
8103 Path::new("/tmp"),
8104 &crate::workdir_fs::WorkdirRoot::open(Path::new("/tmp")).unwrap(),
8105 )
8106 .expect("environment_only must never fail");
8107 assert!(matches!(supervisor, SupervisorHandle::Noop));
8108 assert_eq!(
8109 enforcement.session_netns.creations(),
8110 0,
8111 "a tier with no kernel sandbox must not create a network namespace"
8112 );
8113 }
8114
8115 // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
8116 // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
8117 // test passes while exercising a path that enforces nothing. The two tests below assert the
8118 // opposite direction — that allowlisted binaries still run — which any tier does evidence.
8119
8120 #[test]
8121 fn kernel_tier_allows_exec_within_shell_allowlist() {
8122 if crate::network_namespace::skip_without_egress_namespace(
8123 "kernel_tier_allows_exec_within_shell_allowlist",
8124 ) {
8125 return;
8126 }
8127 let tier = detect_enforcement_tier();
8128 if tier == EnforcementTier::EnvironmentOnly {
8129 eprintln!(
8130 "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
8131 EnvironmentOnly on a Linux host — degrading gracefully"
8132 );
8133 return;
8134 }
8135
8136 let temp = tempfile::tempdir().unwrap();
8137 let policy = CapabilityPolicy {
8138 shell_allow: vec!["bash".to_string()],
8139 ..CapabilityPolicy::default()
8140 };
8141 let enforcement = ShellEnforcement {
8142 tier,
8143 network_allow_ips: Vec::new(),
8144 unix_sockets_allowed: policy.unix_sockets_allowed,
8145 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8146 &resolve_exec_allowlist(&policy.shell_allow),
8147 )),
8148 ..host_bounding_base()
8149 };
8150
8151 let result = crate::shell::execute_shell(
8152 "bash",
8153 &["-c", "exit 7"],
8154 &[],
8155 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8156 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8157 &policy,
8158 &enforcement,
8159 )
8160 .unwrap();
8161
8162 assert_eq!(
8163 result.exit_code, 7,
8164 "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
8165 proving the allowlist isn't a blanket deny"
8166 );
8167 }
8168
8169 #[test]
8170 fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
8171 if crate::network_namespace::skip_without_egress_namespace(
8172 "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
8173 ) {
8174 return;
8175 }
8176 let tier = detect_enforcement_tier();
8177 if tier == EnforcementTier::EnvironmentOnly {
8178 eprintln!(
8179 "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
8180 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
8181 );
8182 return;
8183 }
8184
8185 let temp = tempfile::tempdir().unwrap();
8186 let policy = CapabilityPolicy {
8187 shell_allow: vec!["bash".to_string(), "ls".to_string()],
8188 ..CapabilityPolicy::default()
8189 };
8190 let enforcement = ShellEnforcement {
8191 tier,
8192 network_allow_ips: Vec::new(),
8193 unix_sockets_allowed: policy.unix_sockets_allowed,
8194 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8195 &resolve_exec_allowlist(&policy.shell_allow),
8196 )),
8197 ..host_bounding_base()
8198 };
8199
8200 // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
8201 // bash — the case the no-regression scenario ("allowlisted binary still functions")
8202 // actually cares about.
8203 let result = crate::shell::execute_shell(
8204 "bash",
8205 &["-c", "ls"],
8206 &[],
8207 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8208 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8209 &policy,
8210 &enforcement,
8211 )
8212 .unwrap();
8213
8214 assert_eq!(
8215 result.exit_code, 0,
8216 "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
8217 (stderr: {})",
8218 result.stderr
8219 );
8220 }
8221
8222 #[test]
8223 fn kernel_tier_denies_network_connect_outside_allowlist() {
8224 if crate::network_namespace::skip_without_egress_namespace(
8225 "kernel_tier_denies_network_connect_outside_allowlist",
8226 ) {
8227 return;
8228 }
8229 let tier = detect_enforcement_tier();
8230 if tier == EnforcementTier::EnvironmentOnly {
8231 eprintln!(
8232 "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
8233 EnvironmentOnly on a Linux host — degrading gracefully"
8234 );
8235 return;
8236 }
8237
8238 // A real listener on the *host*, so the destination port is genuinely open. The failure
8239 // below is therefore about reachability, not about a closed port: the subprocess runs in
8240 // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
8241 // listener does not exist at all.
8242 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
8243 let port = listener.local_addr().unwrap().port();
8244
8245 let temp = tempfile::tempdir().unwrap();
8246 let policy = CapabilityPolicy {
8247 shell_allow: vec!["bash".to_string()],
8248 ..CapabilityPolicy::default()
8249 };
8250 let enforcement = ShellEnforcement {
8251 tier,
8252 // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
8253 // capsule found its way to the endpoint.
8254 network_allow_rules: Vec::new(),
8255 network_allow_ips: Vec::new(),
8256 unix_sockets_allowed: policy.unix_sockets_allowed,
8257 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8258 &resolve_exec_allowlist(&policy.shell_allow),
8259 )),
8260 ..host_bounding_base()
8261 };
8262
8263 // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
8264 let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
8265 let result = crate::shell::execute_shell(
8266 "bash",
8267 &["-c", &script],
8268 &[],
8269 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8270 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8271 &policy,
8272 &enforcement,
8273 )
8274 .unwrap();
8275
8276 assert_ne!(
8277 result.exit_code, 0,
8278 "a direct connect() to a destination outside capabilities.network.allow must fail \
8279 even though the port is really open on the host — the subprocess is in its own \
8280 network namespace with no route to it"
8281 );
8282 }
8283
8284 /// The no-regression counterpart of the test above: enforcement must add denials for
8285 /// out-of-policy destinations without breaking permitted ones.
8286 ///
8287 /// The allowlist has to be declared rather than only pre-resolved, because it decides two
8288 /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
8289 /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
8290 ///
8291 /// Deliberately *not* a test of the security property — a green suite proves nothing about
8292 /// the kernel-level claim, and the real check is the hand-run procedure in
8293 /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
8294 /// asserts only that the permitted path still functions.
8295 #[test]
8296 fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
8297 if crate::network_namespace::skip_without_egress_namespace(
8298 "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
8299 ) {
8300 return;
8301 }
8302 let tier = detect_enforcement_tier();
8303 if tier == EnforcementTier::EnvironmentOnly {
8304 eprintln!(
8305 "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
8306 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
8307 );
8308 return;
8309 }
8310
8311 // A real listener on the host, answering one connection, so a success below means bytes
8312 // really crossed the namespace boundary through the proxy rather than merely connecting.
8313 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
8314 let port = listener.local_addr().unwrap().port();
8315 let server = std::thread::spawn(move || {
8316 if let Ok((mut stream, _)) = listener.accept() {
8317 use std::io::Write;
8318 let _ = stream.write_all(b"pong\n");
8319 }
8320 });
8321
8322 let temp = tempfile::tempdir().unwrap();
8323 // Naming the port is required, not incidental: it is what puts a listener on that port
8324 // inside the namespace. See `egress_proxy::egress_listen_ports`.
8325 let allow = vec![format!("http://127.0.0.1:{port}")];
8326 let policy = CapabilityPolicy {
8327 shell_allow: vec!["bash".to_string()],
8328 network_allow: allow.clone(),
8329 ..CapabilityPolicy::default()
8330 };
8331 let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
8332 let enforcement = ShellEnforcement {
8333 tier,
8334 egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
8335 network_allow_rules: rules,
8336 network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
8337 unix_sockets_allowed: policy.unix_sockets_allowed,
8338 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8339 &resolve_exec_allowlist(&policy.shell_allow),
8340 )),
8341 ..host_bounding_base()
8342 };
8343
8344 // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
8345 // and this measures the network path alone.
8346 let script =
8347 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
8348 let result = crate::shell::execute_shell(
8349 "bash",
8350 &["-c", &script],
8351 &[],
8352 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8353 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8354 &policy,
8355 &enforcement,
8356 )
8357 .unwrap();
8358 let _ = server.join();
8359
8360 assert_eq!(
8361 result.exit_code, 0,
8362 "a destination inside capabilities.network.allow must stay reachable — enforcement \
8363 adds denials for out-of-policy destinations, it does not degrade permitted ones \
8364 (stderr: {})",
8365 result.stderr
8366 );
8367 assert!(
8368 result.stdout.contains("pong"),
8369 "the host listener's bytes must come back through the proxy unmodified \
8370 (stdout: {:?}, stderr: {})",
8371 result.stdout,
8372 result.stderr
8373 );
8374 }
8375
8376 #[test]
8377 fn kernel_full_denies_filesystem_access_outside_workdir() {
8378 if crate::network_namespace::skip_without_egress_namespace(
8379 "kernel_full_denies_filesystem_access_outside_workdir",
8380 ) {
8381 return;
8382 }
8383 let tier = detect_enforcement_tier();
8384 if tier != EnforcementTier::KernelFull {
8385 eprintln!(
8386 "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
8387 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
8388 instead"
8389 );
8390 return;
8391 }
8392
8393 let temp = tempfile::tempdir().unwrap();
8394 let policy = CapabilityPolicy {
8395 shell_allow: vec!["bash".to_string()],
8396 ..CapabilityPolicy::default()
8397 };
8398 let enforcement = ShellEnforcement {
8399 tier,
8400 network_allow_ips: Vec::new(),
8401 unix_sockets_allowed: policy.unix_sockets_allowed,
8402 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8403 &resolve_exec_allowlist(&policy.shell_allow),
8404 )),
8405 ..host_bounding_base()
8406 };
8407
8408 let result = crate::shell::execute_shell(
8409 "bash",
8410 &["-c", "cat /etc/hostname"],
8411 &[],
8412 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8413 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8414 &policy,
8415 &enforcement,
8416 )
8417 .unwrap();
8418
8419 assert_ne!(
8420 result.exit_code, 0,
8421 "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
8422 );
8423 }
8424
8425 // ---- moves, links and truncation under the ruleset ABI ----
8426 //
8427 // The tests below resolve their enforcement the way a session does — `ShellEnforcement::resolve`
8428 // over a live `HostProbe` at `scoped` — so the ruleset ABI they run under is the one the probe
8429 // fills. Each one that needs a newer ABI than this host offers prints a skip line and returns.
8430
8431 /// A `scoped` session's enforcement for `policy`, from a live probe, or `None` (after a skip
8432 /// line naming `test`) when this host cannot install a Landlock domain at `min_abi` or above.
8433 fn scoped_landlock_enforcement(
8434 test: &str,
8435 policy: &CapabilityPolicy,
8436 min_abi: u8,
8437 ) -> Option<ShellEnforcement> {
8438 if crate::network_namespace::skip_without_egress_namespace(test) {
8439 return None;
8440 }
8441 let probe = HostProbe::probe();
8442 let enforcement =
8443 ShellEnforcement::resolve(policy, murmur_artifact::ContainmentClass::Scoped, probe)
8444 .unwrap();
8445 if enforcement.tier != EnforcementTier::KernelFull
8446 || enforcement.landlock_abi < Some(min_abi)
8447 {
8448 eprintln!(
8449 "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
8450 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
8451 enforcement.tier,
8452 enforcement.landlock_abi,
8453 probe.landlock_kernel_abi()
8454 );
8455 return None;
8456 }
8457 Some(enforcement)
8458 }
8459
8460 fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
8461 let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
8462 .iter()
8463 .map(|name| name.to_string())
8464 .collect();
8465 shell_allow.extend(extra.iter().map(|name| name.to_string()));
8466 CapabilityPolicy {
8467 shell_allow,
8468 ..CapabilityPolicy::default()
8469 }
8470 }
8471
8472 fn run_bash_in(
8473 workdir: &Path,
8474 script: &str,
8475 policy: &CapabilityPolicy,
8476 enforcement: &ShellEnforcement,
8477 ) -> crate::shell::ShellResult {
8478 let result = crate::shell::execute_shell(
8479 "bash",
8480 &["-c", script],
8481 &[],
8482 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
8483 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
8484 policy,
8485 enforcement,
8486 )
8487 .unwrap();
8488 println!(
8489 "exit {}\n--- stdout\n{}--- stderr\n{}---",
8490 result.exit_code, result.stdout, result.stderr
8491 );
8492 result
8493 }
8494
8495 /// A `<base>/work` workdir with a sibling `<base>/outside` on the same filesystem, so a
8496 /// refusal between them is Landlock's and never the VFS's cross-mount `EXDEV`.
8497 fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
8498 let base = tempfile::tempdir().unwrap();
8499 let work = base.path().join("work");
8500 let outside = base.path().join("outside");
8501 std::fs::create_dir(&work).unwrap();
8502 std::fs::create_dir(&outside).unwrap();
8503 (base, work, outside)
8504 }
8505
8506 /// With `Refer` granted on the workdir, `mv` between two of its folders is a real `rename(2)`
8507 /// (the inode survives), and `ln` and a directory reparent across folders both succeed.
8508 #[test]
8509 fn kernel_tier_moves_files_between_workdir_folders() {
8510 let test = "kernel_tier_moves_files_between_workdir_folders";
8511 let policy = coreutils_policy(&[]);
8512 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
8513 return;
8514 };
8515 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
8516 assert_eq!(enforcement.landlock_abi, Some(3));
8517
8518 let temp = tempfile::tempdir().unwrap();
8519 let result = run_bash_in(
8520 temp.path(),
8521 r#"set -e
8522mkdir a b
8523echo payload > a/x
8524before=$(stat -c %i a/x)
8525mv a/x b/x
8526after=$(stat -c %i b/x)
8527echo "mv a/x b/x: inode $before -> $after"
8528[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
8529ln b/x a/y
8530[ "$(cat a/y)" = payload ] || exit 11
8531echo "ln b/x a/y: ok"
8532mkdir a/d
8533mv a/d b/d
8534[ -d b/d ] && [ ! -e a/d ] || exit 12
8535echo "mv a/d b/d: ok"
8536"#,
8537 &policy,
8538 &enforcement,
8539 );
8540
8541 assert_eq!(
8542 result.exit_code, 0,
8543 "a move, a hard link and a directory reparent between workdir folders must all \
8544 succeed under Refer"
8545 );
8546 }
8547
8548 /// The ruleset a 5.13–5.18 kernel gets, forced through the same `landlock_abi` field the probe
8549 /// fills: the capsule still launches, `ln` across folders is `EXDEV`, and `mv` survives only
8550 /// by copying.
8551 #[test]
8552 fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
8553 let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
8554 let policy = coreutils_policy(&[]);
8555 let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
8556 return;
8557 };
8558 enforcement.landlock_abi = Some(1);
8559
8560 let temp = tempfile::tempdir().unwrap();
8561 let result = run_bash_in(
8562 temp.path(),
8563 r#"mkdir a b || exit 20
8564echo payload > a/x || exit 21
8565before=$(stat -c %i a/x)
8566mv a/x b/x || exit 22
8567[ -f b/x ] && [ ! -e a/x ] || exit 23
8568after=$(stat -c %i b/x)
8569echo "mv a/x b/x: inode $before -> $after"
8570[ "$before" != "$after" ] || exit 24
8571if ln b/x a/y; then exit 25; fi
8572exit 42
8573"#,
8574 &policy,
8575 &enforcement,
8576 );
8577
8578 assert_eq!(
8579 result.exit_code, 42,
8580 "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
8581 back to copy-and-delete (a new inode) and ln fails"
8582 );
8583 assert!(
8584 result.stderr.contains("Invalid cross-device link"),
8585 "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
8586 result.stderr
8587 );
8588 }
8589
8590 /// Nothing is moved or linked out of the workdir into a sibling folder on the same filesystem.
8591 #[test]
8592 fn kernel_tier_refuses_rename_out_of_the_workdir() {
8593 let test = "kernel_tier_refuses_rename_out_of_the_workdir";
8594 let policy = coreutils_policy(&[]);
8595 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
8596 return;
8597 };
8598
8599 let (_base, work, outside) = work_and_outside();
8600 let script = format!(
8601 r#"echo payload > x || exit 20
8602if mv x '{outside}/x'; then exit 21; fi
8603if ln x '{outside}/x2'; then exit 22; fi
8604exit 0
8605"#,
8606 outside = outside.display()
8607 );
8608 let result = run_bash_in(&work, &script, &policy, &enforcement);
8609
8610 assert_eq!(
8611 result.exit_code, 0,
8612 "mv and ln out of the workdir must both fail"
8613 );
8614 assert!(
8615 work.join("x").exists(),
8616 "the source must stay in the workdir"
8617 );
8618 assert!(!outside.join("x").exists(), "nothing may be moved out");
8619 assert!(!outside.join("x2").exists(), "nothing may be linked out");
8620 }
8621
8622 /// Nothing is moved or linked into the workdir from a sibling folder on the same filesystem.
8623 #[test]
8624 fn kernel_tier_refuses_rename_into_the_workdir() {
8625 let test = "kernel_tier_refuses_rename_into_the_workdir";
8626 let policy = coreutils_policy(&[]);
8627 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
8628 return;
8629 };
8630
8631 let (_base, work, outside) = work_and_outside();
8632 std::fs::write(outside.join("y"), "original\n").unwrap();
8633 let script = format!(
8634 r#"if mv '{outside}/y' ./y; then exit 21; fi
8635if ln '{outside}/y' ./y2; then exit 22; fi
8636exit 0
8637"#,
8638 outside = outside.display()
8639 );
8640 let result = run_bash_in(&work, &script, &policy, &enforcement);
8641
8642 assert_eq!(
8643 result.exit_code, 0,
8644 "mv and ln into the workdir must both fail"
8645 );
8646 assert_eq!(
8647 std::fs::read_to_string(outside.join("y")).unwrap(),
8648 "original\n",
8649 "the outside file must be untouched"
8650 );
8651 assert!(!work.join("y").exists(), "nothing may be moved in");
8652 assert!(!work.join("y2").exists(), "nothing may be linked in");
8653 }
8654
8655 /// A by-path `truncate(2)` on a host file outside the workdir: refused once the ruleset
8656 /// handles `Truncate` (ABI v3), and not mediated at all at ABI v1 — the gap v3 closes.
8657 #[test]
8658 fn kernel_tier_truncate_outside_the_workdir_is_refused() {
8659 use std::os::unix::fs::PermissionsExt;
8660
8661 let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
8662 let policy = coreutils_policy(&["perl"]);
8663 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
8664 return;
8665 };
8666
8667 let (_base, work, outside) = work_and_outside();
8668 let target = outside.join("t");
8669 std::fs::write(&target, "keep\n").unwrap();
8670 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
8671 let script = format!(
8672 r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
8673 target.display()
8674 );
8675
8676 let refused = run_bash_in(&work, &script, &policy, &enforcement);
8677 assert_ne!(
8678 refused.exit_code, 0,
8679 "truncate(2) outside the workdir must be refused at ABI 3"
8680 );
8681 assert!(
8682 refused.stderr.contains("Permission denied"),
8683 "the refusal must be EACCES; stderr: {}",
8684 refused.stderr
8685 );
8686 assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
8687
8688 let mut at_v1 = enforcement.clone();
8689 at_v1.landlock_abi = Some(1);
8690 let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
8691 assert_eq!(
8692 unmediated.exit_code, 0,
8693 "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
8694 );
8695 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
8696 }
8697
8698 /// Handling `Truncate` must not refuse what a capsule truncates legitimately: its own files,
8699 /// with `truncate` and with a `>` redirect over an existing file, and `/dev/null`.
8700 #[test]
8701 fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
8702 let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
8703 let policy = coreutils_policy(&["truncate"]);
8704 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
8705 return;
8706 };
8707
8708 let temp = tempfile::tempdir().unwrap();
8709 let result = run_bash_in(
8710 temp.path(),
8711 r#"set -e
8712echo hello > f && truncate -s 0 f && [ ! -s f ]
8713echo again > f && echo over > f && [ "$(cat f)" = over ]
8714: > g
8715echo discard > /dev/null
8716echo ok
8717"#,
8718 &policy,
8719 &enforcement,
8720 );
8721
8722 assert_eq!(
8723 result.exit_code, 0,
8724 "truncating inside the workdir and writing to /dev/null must keep working"
8725 );
8726 }
8727
8728 /// `uv venv` then `uv pip install <wheel>` in the workdir, offline: uv unpacks the wheel into
8729 /// a temporary folder in its cache and renames it into place. Passes at the probed ABI, and
8730 /// fails with `EXDEV` at ABI v1, so the pass is `Refer`'s.
8731 ///
8732 /// Ignored because it needs two host-side inputs a stock host does not have:
8733 /// `MURMUR_TEST_UV`, an absolute path to a `uv` binary, and `MURMUR_TEST_WHEEL`, an absolute
8734 /// path to `six-1.16.0-py2.py3-none-any.whl`, whose module and version the script checks after
8735 /// installing it. Run without them it
8736 /// panics naming the missing variable. `uv`, `python3` and its stdlib are granted by this
8737 /// test's policy only.
8738 #[test]
8739 #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
8740 fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
8741 let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
8742 let input = |name: &str| {
8743 let value = std::env::var(name)
8744 .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
8745 let path = PathBuf::from(value);
8746 assert!(
8747 path.is_absolute() && path.is_file(),
8748 "{name}={} is not a file",
8749 path.display()
8750 );
8751 path
8752 };
8753 let uv = input("MURMUR_TEST_UV");
8754 let wheel = input("MURMUR_TEST_WHEEL");
8755 let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
8756 let stdlib = std::process::Command::new(&python)
8757 .args([
8758 "-I",
8759 "-c",
8760 "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
8761 ])
8762 .output()
8763 .expect("host python3 runs");
8764 let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
8765
8766 let policy = CapabilityPolicy {
8767 shell_allow: vec![
8768 "bash".to_string(),
8769 uv.display().to_string(),
8770 python.display().to_string(),
8771 ],
8772 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
8773 binary: "python3".to_string(),
8774 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
8775 path: stdlib,
8776 list_dir: true,
8777 }],
8778 }],
8779 ..CapabilityPolicy::default()
8780 };
8781 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
8782 return;
8783 };
8784
8785 let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
8786 let script = format!(
8787 r#"set -e
8788export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
8789'{uv}' venv --python '{python}' .venv
8790'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
8791.venv/bin/python -c 'import six; print(six.__version__)'
8792"#,
8793 uv = uv.display(),
8794 python = python.display(),
8795 );
8796 let install = |enforcement: &ShellEnforcement| {
8797 let temp = tempfile::tempdir().unwrap();
8798 std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
8799 run_bash_in(temp.path(), &script, &policy, enforcement)
8800 };
8801
8802 let at_probed = install(&enforcement);
8803 assert_eq!(
8804 at_probed.exit_code, 0,
8805 "uv must install the wheel at ABI {:?}",
8806 enforcement.landlock_abi
8807 );
8808 assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
8809
8810 let mut at_v1 = enforcement.clone();
8811 at_v1.landlock_abi = Some(1);
8812 let refused = install(&at_v1);
8813 let output = format!("{}{}", refused.stdout, refused.stderr);
8814 assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
8815 assert!(
8816 output.contains("os error 18") || output.contains("Invalid cross-device link"),
8817 "at ABI v1 the install must fail with EXDEV: {output}"
8818 );
8819 }
8820
8821 // The two tests below exercise the derived Landlock grants. A green run is not evidence they
8822 // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
8823 // no Landlock domain is installed at all. Both tests print a skip line and return when the
8824 // host is not `KernelFull`.
8825
8826 #[test]
8827 fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
8828 {
8829 if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
8830 return;
8831 }
8832 let tier = detect_enforcement_tier();
8833 if tier != EnforcementTier::KernelFull {
8834 eprintln!(
8835 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
8836 kernel_full_runs_nontrivial_shell_allowlist_... requires \
8837 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
8838 This run does NOT execute the derived-grant Landlock code path at all, so a \
8839 green result here is not evidence the workdir-scope exec bug is fixed — only a \
8840 real KernelFull Linux host can prove that."
8841 );
8842 return;
8843 }
8844
8845 let temp = tempfile::tempdir().unwrap();
8846 // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
8847 // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
8848 // Linux hosts and both live outside the workdir, so this only succeeds if the derived
8849 // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
8850 let policy = CapabilityPolicy {
8851 shell_allow: vec!["bash".to_string(), "cat".to_string()],
8852 ..CapabilityPolicy::default()
8853 };
8854 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
8855 let enforcement = ShellEnforcement {
8856 tier,
8857 network_allow_ips: Vec::new(),
8858 unix_sockets_allowed: policy.unix_sockets_allowed,
8859 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8860 &exec_allow_paths,
8861 )),
8862 ..host_bounding_base()
8863 };
8864
8865 // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
8866 // nested, dynamically-linked exec — the case the whole fix exists for.
8867 let result = crate::shell::execute_shell(
8868 "bash",
8869 &["-c", "echo landlock-grant-ok | cat"],
8870 &[],
8871 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8872 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8873 &policy,
8874 &enforcement,
8875 )
8876 .expect("execute_shell should return Ok, not Err");
8877
8878 assert_eq!(
8879 result.exit_code, 0,
8880 "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
8881 must let each exec and dynamic-link outside the workdir (stderr: {})",
8882 result.stderr
8883 );
8884 assert!(
8885 result.stdout.contains("landlock-grant-ok"),
8886 "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
8887 result.stdout,
8888 result.stderr
8889 );
8890 }
8891
8892 #[test]
8893 fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
8894 if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
8895 return;
8896 }
8897 let tier = detect_enforcement_tier();
8898 if tier != EnforcementTier::KernelFull {
8899 eprintln!(
8900 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
8901 kernel_full_denies_write_outside_workdir_... requires \
8902 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
8903 This run does NOT execute the Landlock code path, so a green result here is not \
8904 evidence writes outside the workdir are actually denied — only a real KernelFull \
8905 Linux host can prove that."
8906 );
8907 return;
8908 }
8909
8910 let workdir = tempfile::tempdir().unwrap();
8911 // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
8912 // reason a write can fail is the Landlock scope — not a missing directory.
8913 let outside = tempfile::tempdir().unwrap();
8914 let target = outside.path().join("should-not-be-written.txt");
8915
8916 let policy = CapabilityPolicy {
8917 shell_allow: vec!["bash".to_string()],
8918 ..CapabilityPolicy::default()
8919 };
8920 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
8921 let enforcement = ShellEnforcement {
8922 tier,
8923 network_allow_ips: Vec::new(),
8924 unix_sockets_allowed: policy.unix_sockets_allowed,
8925 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
8926 &exec_allow_paths,
8927 )),
8928 ..host_bounding_base()
8929 };
8930
8931 // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
8932 // write itself is what Landlock must refuse. The derived read+execute grants never widen
8933 // to write, so nothing outside the workdir (grant paths included) is writable.
8934 let script = format!("echo pwned > '{}'", target.display());
8935 let result = crate::shell::execute_shell(
8936 "bash",
8937 &["-c", &script],
8938 &[],
8939 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
8940 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
8941 &policy,
8942 &enforcement,
8943 )
8944 .expect("execute_shell should return Ok with a nonzero exit code, not Err");
8945
8946 assert!(
8947 !target.exists(),
8948 "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
8949 );
8950 assert_ne!(
8951 result.exit_code, 0,
8952 "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
8953 );
8954 }
8955
8956 /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
8957 /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
8958 /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
8959 fn kernel_full_enforcement(
8960 tier: EnforcementTier,
8961 policy: &CapabilityPolicy,
8962 ) -> ShellEnforcement {
8963 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
8964 let mut landlock_grants =
8965 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
8966 landlock_grants.extend(resolve_interpreter_runtime_grants(
8967 &policy.shell_interpreter_runtime,
8968 ));
8969 ShellEnforcement {
8970 tier,
8971 network_allow_ips: Vec::new(),
8972 unix_sockets_allowed: policy.unix_sockets_allowed,
8973 landlock_grants,
8974 ..host_bounding_base()
8975 }
8976 }
8977
8978 /// A real sealed run against two different workdirs: the composed root's `/tmp` and its
8979 /// synthetic `/etc/passwd` both have to resolve from the session workdir, and the capsule's
8980 /// own directory has to gain nothing from either.
8981 ///
8982 /// This is the one test that drives a composed root end to end, so it needs a host that can
8983 /// actually reach `KernelSealed` and skips loudly on one that cannot; the parent-side halves
8984 /// are covered unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports
8985 /// the same host answer this checks.
8986 #[test]
8987 fn a_sealed_command_finds_tmp_and_etc_after_they_moved_into_the_session_workdir() {
8988 if crate::network_namespace::skip_without_egress_namespace(
8989 "a_sealed_command_finds_tmp_and_etc_after_they_moved_into_the_session_workdir",
8990 ) {
8991 return;
8992 }
8993 let probe = HostProbe::probe();
8994 if probe.tier() != EnforcementTier::KernelSealed {
8995 eprintln!(
8996 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
8997 a_sealed_command_finds_tmp_and_etc_... needs a host that reaches KernelSealed \
8998 (unprivileged user namespaces plus a usable Landlock ABI); detected {:?}. No \
8999 composed root is built in this run.",
9000 probe.tier()
9001 );
9002 return;
9003 }
9004
9005 let accessible = tempfile::tempdir().unwrap();
9006 let session = accessible.path().join(".murmur").join("ses_sealed_e2e");
9007 std::fs::create_dir_all(&session).unwrap();
9008 let policy = CapabilityPolicy {
9009 shell_allow: vec!["bash".to_string(), "cat".to_string(), "id".to_string()],
9010 ..CapabilityPolicy::default()
9011 };
9012 let runtime = tempfile::tempdir().unwrap();
9013 let enforcement =
9014 ShellEnforcement::resolve(&policy, murmur_artifact::ContainmentClass::Sealed, probe)
9015 .expect("a sealed-capable host resolves sealed enforcement")
9016 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
9017 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
9018
9019 let result = crate::shell::execute_shell(
9020 "bash",
9021 &["-c", "echo t > /tmp/probe && cat /tmp/probe && id -un"],
9022 &[],
9023 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
9024 &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
9025 &policy,
9026 &enforcement,
9027 )
9028 .expect("execute_shell returns Ok even when the command itself fails");
9029
9030 assert_eq!(
9031 result.exit_code, 0,
9032 "stdout: {} stderr: {}",
9033 result.stdout, result.stderr
9034 );
9035 let mut lines = result.stdout.lines();
9036 assert_eq!(
9037 lines.next(),
9038 Some("t"),
9039 "the capsule's /tmp must be writable"
9040 );
9041 assert!(
9042 lines.next().is_some_and(|name| !name.is_empty()),
9043 "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
9044 {}",
9045 result.stdout
9046 );
9047
9048 assert_eq!(
9049 std::fs::read_to_string(
9050 session
9051 .join(crate::sealed::SEALED_TMP_DIR_NAME)
9052 .join("probe")
9053 )
9054 .unwrap(),
9055 "t\n",
9056 "the bytes written to /tmp land in the session workdir's own store"
9057 );
9058 assert!(runtime
9059 .path()
9060 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
9061 .join("passwd")
9062 .is_file());
9063 for name in [
9064 crate::sealed::SEALED_TMP_DIR_NAME,
9065 ".mur-etc",
9066 crate::shell::SYNTHETIC_HOME_DIR_NAME,
9067 ] {
9068 assert!(
9069 !accessible.path().join(name).exists(),
9070 "enabling sealed must add nothing to the top level of the accessible workdir, \
9071 and it added {name}"
9072 );
9073 }
9074 }
9075
9076 // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
9077 // return when the host is not `KernelFull`.
9078
9079 #[test]
9080 fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
9081 if crate::network_namespace::skip_without_egress_namespace(
9082 "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
9083 ) {
9084 return;
9085 }
9086 let tier = detect_enforcement_tier();
9087 if tier != EnforcementTier::KernelFull {
9088 eprintln!(
9089 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
9090 kernel_full_interpreter_runtime_list_dir_false_... requires \
9091 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
9092 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
9093 result here is not evidence list_dir:false behaves correctly — only a real \
9094 KernelFull Linux host can prove that."
9095 );
9096 return;
9097 }
9098
9099 let workdir = tempfile::tempdir().unwrap();
9100 // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
9101 let stdlib = tempfile::tempdir().unwrap();
9102 let module = stdlib.path().join("mod.py");
9103 std::fs::write(&module, "x = 1\n").unwrap();
9104
9105 let policy = CapabilityPolicy {
9106 shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
9107 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
9108 binary: "bash".to_string(),
9109 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
9110 path: stdlib.path().to_string_lossy().into_owned(),
9111 list_dir: false,
9112 }],
9113 }],
9114 ..CapabilityPolicy::default()
9115 };
9116 let enforcement = kernel_full_enforcement(tier, &policy);
9117
9118 // Opening the file by its exact known name must succeed — Landlock's read rights apply to
9119 // the subtree beneath the granted directory.
9120 let read = crate::shell::execute_shell(
9121 "bash",
9122 &["-c", &format!("cat '{}'", module.display())],
9123 &[],
9124 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9125 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9126 &policy,
9127 &enforcement,
9128 )
9129 .expect("execute_shell should return Ok");
9130 assert_eq!(
9131 read.exit_code, 0,
9132 "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
9133 read.stderr
9134 );
9135 assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
9136
9137 // Listing the directory's own entries must fail — ReadDir was not granted.
9138 let list = crate::shell::execute_shell(
9139 "bash",
9140 &["-c", &format!("ls '{}'", stdlib.path().display())],
9141 &[],
9142 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9143 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9144 &policy,
9145 &enforcement,
9146 )
9147 .expect("execute_shell should return Ok with a nonzero exit code");
9148 assert_ne!(
9149 list.exit_code, 0,
9150 "list_dir:false must deny enumerating the granted directory's own entries"
9151 );
9152 }
9153
9154 #[test]
9155 fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
9156 if crate::network_namespace::skip_without_egress_namespace(
9157 "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
9158 ) {
9159 return;
9160 }
9161 let tier = detect_enforcement_tier();
9162 if tier != EnforcementTier::KernelFull {
9163 eprintln!(
9164 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
9165 kernel_full_interpreter_runtime_list_dir_true_... requires \
9166 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
9167 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
9168 result here is not evidence list_dir:true (and its non-widening to the parent) \
9169 behaves correctly — only a real KernelFull Linux host can prove that."
9170 );
9171 return;
9172 }
9173
9174 let workdir = tempfile::tempdir().unwrap();
9175 // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
9176 let parent = tempfile::tempdir().unwrap();
9177 let stdlib = parent.path().join("stdlib");
9178 std::fs::create_dir(&stdlib).unwrap();
9179 std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
9180
9181 let policy = CapabilityPolicy {
9182 shell_allow: vec!["bash".to_string(), "ls".to_string()],
9183 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
9184 binary: "bash".to_string(),
9185 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
9186 path: stdlib.to_string_lossy().into_owned(),
9187 list_dir: true,
9188 }],
9189 }],
9190 ..CapabilityPolicy::default()
9191 };
9192 let enforcement = kernel_full_enforcement(tier, &policy);
9193
9194 // Listing the granted directory must succeed.
9195 let list = crate::shell::execute_shell(
9196 "bash",
9197 &["-c", &format!("ls '{}'", stdlib.display())],
9198 &[],
9199 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9200 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9201 &policy,
9202 &enforcement,
9203 )
9204 .expect("execute_shell should return Ok");
9205 assert_eq!(
9206 list.exit_code, 0,
9207 "list_dir:true must permit enumerating the granted directory (stderr: {})",
9208 list.stderr
9209 );
9210 assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
9211
9212 // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
9213 // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
9214 let list_parent = crate::shell::execute_shell(
9215 "bash",
9216 &["-c", &format!("ls '{}'", parent.path().display())],
9217 &[],
9218 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9219 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9220 &policy,
9221 &enforcement,
9222 )
9223 .expect("execute_shell should return Ok with a nonzero exit code");
9224 assert_ne!(
9225 list_parent.exit_code, 0,
9226 "a grant on stdlib/ must not make its (ungranted) parent enumerable"
9227 );
9228 }
9229
9230 /// The `executable` axis, asserted against a real Landlock kernel.
9231 ///
9232 /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
9233 /// readable and enumerable without becoming a place to run programs from. It is asserted here
9234 /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
9235 /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
9236 /// would have to be driven through a composed root, which no unit test can reach.
9237 ///
9238 /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
9239 /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
9240 #[test]
9241 fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
9242 if crate::network_namespace::skip_without_egress_namespace(
9243 "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
9244 ) {
9245 return;
9246 }
9247 let host_tier = detect_enforcement_tier();
9248 if !matches!(
9249 host_tier,
9250 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
9251 ) {
9252 eprintln!(
9253 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
9254 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
9255 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
9256 result is not evidence that withholding Execute denies execve."
9257 );
9258 return;
9259 }
9260
9261 let workdir = tempfile::tempdir().unwrap();
9262 // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
9263 let tree = tempfile::tempdir().unwrap();
9264 std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
9265 let runme = tree.path().join("runme");
9266 std::fs::copy("/bin/echo", &runme).unwrap();
9267
9268 let policy = CapabilityPolicy {
9269 shell_allow: vec!["bash".to_string(), "ls".to_string()],
9270 ..CapabilityPolicy::default()
9271 };
9272 let with_tree_grant = |executable: bool| {
9273 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
9274 enforcement.landlock_grants.push(LandlockGrant {
9275 path: tree.path().to_path_buf(),
9276 list_dir: true,
9277 executable,
9278 pinned: None,
9279 });
9280 enforcement
9281 };
9282 let run = |enforcement: &ShellEnforcement, command: String| {
9283 crate::shell::execute_shell(
9284 "bash",
9285 &["-c", &command],
9286 &[],
9287 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9288 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9289 &policy,
9290 enforcement,
9291 )
9292 .expect("execute_shell should return Ok even when the command itself fails")
9293 };
9294
9295 let denied = with_tree_grant(false);
9296 let list = run(&denied, format!("ls '{}'", tree.path().display()));
9297 assert_eq!(
9298 list.exit_code, 0,
9299 "a non-executable grant must still be enumerable — that is its entire purpose \
9300 (stderr: {})",
9301 list.stderr
9302 );
9303 assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
9304
9305 let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
9306 assert_ne!(
9307 exec_denied.exit_code, 0,
9308 "withholding Execute must deny execve inside the granted tree; a program ran instead \
9309 (stdout: {})",
9310 exec_denied.stdout
9311 );
9312 assert!(
9313 !exec_denied.stdout.contains("ran"),
9314 "the binary produced output, so it executed: {}",
9315 exec_denied.stdout
9316 );
9317
9318 // The control: the identical grant with `executable: true` does run it. Without this the
9319 // assertion above could be passing for an unrelated reason (a bad path, a missing library).
9320 let allowed = with_tree_grant(true);
9321 let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
9322 assert_eq!(
9323 exec_allowed.exit_code, 0,
9324 "the same tree granted Execute must run (stderr: {})",
9325 exec_allowed.stderr
9326 );
9327 assert!(
9328 exec_allowed.stdout.contains("ran"),
9329 "stdout: {}",
9330 exec_allowed.stdout
9331 );
9332 }
9333
9334 /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
9335 /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
9336 ///
9337 /// Two kernel facts are pinned here. First, `ReadFile` alone —
9338 /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
9339 /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
9340 /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
9341 /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
9342 /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
9343 /// read, not die in `pre_exec`.
9344 ///
9345 /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
9346 #[test]
9347 fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
9348 if crate::network_namespace::skip_without_egress_namespace(
9349 "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
9350 ) {
9351 return;
9352 }
9353 let host_tier = detect_enforcement_tier();
9354 if !matches!(
9355 host_tier,
9356 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
9357 ) {
9358 eprintln!(
9359 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
9360 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
9361 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
9362 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
9363 a file is narrowed instead of refusing the launch."
9364 );
9365 return;
9366 }
9367
9368 let workdir = tempfile::tempdir().unwrap();
9369 // A trust-store stand-in: a regular file outside the workdir, granted on its own.
9370 let store = tempfile::tempdir().unwrap();
9371 let bundle = store.path().join("ca-certificates.crt");
9372 std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
9373
9374 let policy = CapabilityPolicy {
9375 shell_allow: vec!["bash".to_string(), "cat".to_string()],
9376 ..CapabilityPolicy::default()
9377 };
9378 let with_file_grant = |list_dir: bool| {
9379 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
9380 enforcement.landlock_grants.push(LandlockGrant {
9381 path: bundle.clone(),
9382 list_dir,
9383 executable: false,
9384 pinned: None,
9385 });
9386 enforcement
9387 };
9388 let read = |enforcement: &ShellEnforcement| {
9389 crate::shell::execute_shell(
9390 "bash",
9391 &["-c", &format!("cat '{}'", bundle.display())],
9392 &[],
9393 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9394 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
9395 &policy,
9396 enforcement,
9397 )
9398 .expect("execute_shell should return Ok even when the command itself fails")
9399 };
9400
9401 let honest = read(&with_file_grant(false));
9402 assert_eq!(
9403 honest.exit_code, 0,
9404 "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
9405 file entries rely on (stderr: {})",
9406 honest.stderr
9407 );
9408 assert!(
9409 honest.stdout.contains("BEGIN CERTIFICATE"),
9410 "stdout: {}",
9411 honest.stdout
9412 );
9413
9414 let overclaimed = read(&with_file_grant(true));
9415 assert_eq!(
9416 overclaimed.exit_code, 0,
9417 "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
9418 into an EINVAL that refuses the launch (stderr: {})",
9419 overclaimed.stderr
9420 );
9421 assert!(
9422 overclaimed.stdout.contains("BEGIN CERTIFICATE"),
9423 "stdout: {}",
9424 overclaimed.stdout
9425 );
9426 }
9427
9428 // ---- every distinct pre_exec setup failure is legible + fail-closed ----
9429 //
9430 // These force each of the three distinct sandbox setup failures and assert each produces its
9431 // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
9432 // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
9433 // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
9434 // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
9435 // short-circuits before any real Landlock syscall.
9436
9437 struct ForceNoNewPrivsFailureGuard;
9438
9439 impl ForceNoNewPrivsFailureGuard {
9440 fn new() -> Self {
9441 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
9442 Self
9443 }
9444 }
9445
9446 impl Drop for ForceNoNewPrivsFailureGuard {
9447 fn drop(&mut self) {
9448 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
9449 }
9450 }
9451
9452 struct ForceLandlockFailureGuard;
9453
9454 impl ForceLandlockFailureGuard {
9455 fn new() -> Self {
9456 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
9457 Self
9458 }
9459 }
9460
9461 impl Drop for ForceLandlockFailureGuard {
9462 fn drop(&mut self) {
9463 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
9464 }
9465 }
9466
9467 /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
9468 /// depend on any specific grant — they force the failure directly — so this exercises the
9469 /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
9470 fn kernel_full_empty_grants() -> ShellEnforcement {
9471 ShellEnforcement {
9472 tier: EnforcementTier::KernelFull,
9473 network_allow_ips: Vec::new(),
9474 unix_sockets_allowed: false,
9475 landlock_grants: Vec::new(),
9476 ..host_bounding_base()
9477 }
9478 }
9479
9480 /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
9481 /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
9482 /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
9483 fn workdir_resolution_error() -> String {
9484 let temp = tempfile::tempdir().unwrap();
9485 let missing = temp.path().join("does-not-exist-workdir");
9486 let marker = temp.path().join("never-spawned.marker");
9487 let mut command = std::process::Command::new("bash");
9488 command
9489 .arg("-c")
9490 .arg(format!("echo ran > '{}'", marker.display()));
9491
9492 let error = prepare_enforcement(
9493 &mut command,
9494 &kernel_full_empty_grants(),
9495 &missing,
9496 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9497 )
9498 .expect_err("an unresolvable workdir must fail before fork()");
9499 assert!(
9500 !marker.exists(),
9501 "prepare_enforcement must never spawn a subprocess: {error}"
9502 );
9503 error
9504 }
9505
9506 /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
9507 /// child-side failure active, asserts the target script never ran (marker absent), and returns
9508 /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
9509 fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
9510 child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
9511 }
9512
9513 /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
9514 /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
9515 /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
9516 /// identity out to the CLI instead of arriving as one more error string.
9517 fn child_setup_failure_typed<G>(
9518 enforcement: &ShellEnforcement,
9519 arm: impl FnOnce() -> G,
9520 ) -> crate::shell::ShellExecError {
9521 let temp = tempfile::tempdir().unwrap();
9522 let policy = CapabilityPolicy {
9523 shell_allow: vec!["bash".to_string()],
9524 ..CapabilityPolicy::default()
9525 };
9526 let marker = temp.path().join("spawned-anyway.marker");
9527 let script = format!("echo ran > '{}'", marker.display());
9528
9529 let _guard = arm();
9530 let error = crate::shell::execute_shell(
9531 "bash",
9532 &["-c", &script],
9533 &[],
9534 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9535 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9536 &policy,
9537 enforcement,
9538 )
9539 .expect_err("a forced child-side setup failure must make execute_shell return Err");
9540 assert!(
9541 !marker.exists(),
9542 "no subprocess may run when pre_exec setup fails: {error}"
9543 );
9544 error
9545 }
9546
9547 #[test]
9548 fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
9549 let error = workdir_resolution_error();
9550 assert!(
9551 error.contains("workdir") && error.contains("does-not-exist-workdir"),
9552 "the error must name the workdir path that failed to resolve: {error}"
9553 );
9554 assert!(
9555 !error.contains("os error 22"),
9556 "must not collapse to the bare EINVAL string: {error}"
9557 );
9558 }
9559
9560 #[test]
9561 fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
9562 if crate::network_namespace::skip_without_egress_namespace(
9563 "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
9564 ) {
9565 return;
9566 }
9567 let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
9568 assert!(
9569 error.contains("no_new_privs"),
9570 "the error must name the no_new_privs step specifically: {error}"
9571 );
9572 assert!(
9573 !error.contains("os error 22"),
9574 "must not collapse to the bare EINVAL string: {error}"
9575 );
9576 }
9577
9578 #[test]
9579 fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
9580 if crate::network_namespace::skip_without_egress_namespace(
9581 "pre_exec_landlock_failure_is_distinct_and_fails_closed",
9582 ) {
9583 return;
9584 }
9585 let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
9586 assert!(
9587 error.contains("landlock"),
9588 "the error must name the Landlock construction step specifically: {error}"
9589 );
9590 assert!(
9591 !error.contains("os error 22"),
9592 "must not collapse to the bare EINVAL string: {error}"
9593 );
9594 }
9595
9596 #[test]
9597 fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
9598 if crate::network_namespace::skip_without_egress_namespace(
9599 "pre_exec_setup_failures_produce_pairwise_distinct_messages",
9600 ) {
9601 return;
9602 }
9603 let workdir_msg = workdir_resolution_error();
9604 let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
9605 let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
9606
9607 let messages = [
9608 ("workdir", &workdir_msg),
9609 ("no_new_privs", &no_new_privs_msg),
9610 ("landlock", &landlock_msg),
9611 ];
9612 for (i, (left_name, left)) in messages.iter().enumerate() {
9613 for (right_name, right) in &messages[i + 1..] {
9614 assert_ne!(left, right, "{left_name} vs {right_name} must differ");
9615 }
9616 }
9617
9618 for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
9619 assert!(!message.is_empty(), "a distinct message must not be empty");
9620 assert!(
9621 !message.contains("os error 22"),
9622 "no failure may read as a bare EINVAL: {message}"
9623 );
9624 }
9625 }
9626
9627 /// The first half of the composed-root failure path: a `pre_exec`
9628 /// composed-root failure comes back out of `execute_shell` as the *typed*
9629 /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
9630 /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
9631 /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
9632 #[test]
9633 fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
9634 if crate::network_namespace::skip_without_egress_namespace(
9635 "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
9636 ) {
9637 return;
9638 }
9639 let error =
9640 child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
9641
9642 assert!(
9643 matches!(
9644 error,
9645 crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
9646 ),
9647 "a sealed-root diagnostic must survive as its own variant, not collapse into \
9648 ShellExecError::Failed: {error}"
9649 );
9650 let fatal = error
9651 .session_fatal()
9652 .expect("a composed-root failure must end the session, not just the tool call");
9653 assert!(
9654 matches!(
9655 fatal,
9656 crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
9657 ),
9658 "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
9659 );
9660 let rendered = error.to_string();
9661 assert!(
9662 rendered.contains("composed root") && rendered.contains("unshare"),
9663 "the message must name both the mechanism and the step that failed: {rendered}"
9664 );
9665 assert!(
9666 !rendered.contains("os error 22"),
9667 "must not collapse to the bare EINVAL string: {rendered}"
9668 );
9669 }
9670
9671 /// The contrast that makes the assertion above mean something: an equally fatal-looking
9672 /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
9673 /// in this path ends a session for a Landlock or `no_new_privs` problem.
9674 #[test]
9675 fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
9676 let landlock =
9677 child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
9678 assert!(
9679 landlock.session_fatal().is_none(),
9680 "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
9681 );
9682
9683 let no_new_privs = child_setup_failure_typed(
9684 &kernel_full_empty_grants(),
9685 ForceNoNewPrivsFailureGuard::new,
9686 );
9687 assert!(
9688 no_new_privs.session_fatal().is_none(),
9689 "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
9690 );
9691 }
9692
9693 // ---- one network namespace per session -------------------------------------------------
9694 //
9695 // Every test below drives real commands through `execute_shell`/`run_shell` against an
9696 // enforcement resolved the way `stage_session` resolves one, and reads what a later command of
9697 // the same session — or of another session — can reach.
9698
9699 /// `bash`, `nc` and `sleep`, plus `extra`: what a model starting and probing a server uses.
9700 fn server_policy(extra: &[&str]) -> CapabilityPolicy {
9701 CapabilityPolicy {
9702 shell_allow: ["bash", "nc", "sleep"]
9703 .iter()
9704 .chain(extra)
9705 .map(|binary| binary.to_string())
9706 .collect(),
9707 ..CapabilityPolicy::default()
9708 }
9709 }
9710
9711 /// A session's enforcement, resolved as `stage_session` resolves a `scoped` capsule's.
9712 fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
9713 let enforcement = ShellEnforcement::resolve(
9714 policy,
9715 murmur_artifact::ContainmentClass::Scoped,
9716 HostProbe::probe(),
9717 )
9718 .expect("the policy resolves");
9719 assert_eq!(
9720 enforcement.tier,
9721 EnforcementTier::KernelFull,
9722 "a scoped capsule on this host runs on KernelFull"
9723 );
9724 enforcement
9725 }
9726
9727 /// A one-shot server on `127.0.0.1:<port>` that answers its one connection with `reply`,
9728 /// backgrounded with its output redirected so the command that starts it returns at once.
9729 fn one_shot_server(reply: &str, port: u16) -> String {
9730 format!(
9731 "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
9732 )
9733 }
9734
9735 /// Connects to `127.0.0.1:<port>`, retrying for up to five seconds while the server comes up,
9736 /// and prints the first line it reads.
9737 fn retrying_client(port: u16) -> String {
9738 format!(
9739 "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
9740 sleep 0.1; done; read -r reply <&3; echo \"$reply\""
9741 )
9742 }
9743
9744 fn bash(
9745 script: &str,
9746 workdir: &Path,
9747 policy: &CapabilityPolicy,
9748 enforcement: &ShellEnforcement,
9749 ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
9750 crate::shell::execute_shell(
9751 "bash",
9752 &["-c", script],
9753 &[],
9754 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
9755 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
9756 policy,
9757 enforcement,
9758 )
9759 }
9760
9761 fn pid_in(workdir: &Path, file: &str) -> i32 {
9762 std::fs::read_to_string(workdir.join(file))
9763 .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
9764 .trim()
9765 .parse()
9766 .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
9767 }
9768
9769 fn process_exists(pid: i32) -> bool {
9770 Path::new(&format!("/proc/{pid}")).exists()
9771 }
9772
9773 /// Whether `/proc/<pid>` is gone within `timeout`. A killed process stays visible until its
9774 /// parent — `init` for an orphan — reaps it, which takes a moment after the kill itself.
9775 fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
9776 let deadline = std::time::Instant::now() + timeout;
9777 while process_exists(pid) {
9778 if std::time::Instant::now() >= deadline {
9779 return false;
9780 }
9781 std::thread::sleep(std::time::Duration::from_millis(20));
9782 }
9783 true
9784 }
9785
9786 /// A server one command backgrounds answers a later, separate command of the same session.
9787 #[test]
9788 #[allow(clippy::print_stdout)]
9789 fn a_listener_started_by_one_command_answers_the_next() {
9790 if crate::network_namespace::skip_without_egress_namespace(
9791 "a_listener_started_by_one_command_answers_the_next",
9792 ) {
9793 return;
9794 }
9795 let temp = tempfile::tempdir().unwrap();
9796 let policy = server_policy(&[]);
9797 let enforcement = session_enforcement(&policy);
9798
9799 let started = std::time::Instant::now();
9800 let server = bash(
9801 &one_shot_server("pong", 8080),
9802 temp.path(),
9803 &policy,
9804 &enforcement,
9805 )
9806 .expect("the server command runs");
9807 let server_ms = started.elapsed().as_millis();
9808 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
9809 assert!(
9810 started.elapsed() < std::time::Duration::from_secs(5),
9811 "a backgrounded server with its output redirected must not hold the command open"
9812 );
9813
9814 let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
9815 .expect("the client command runs");
9816 println!(
9817 "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
9818 namespaces created: {}",
9819 enforcement.tier,
9820 server.exit_code,
9821 client.exit_code,
9822 client.stdout.trim(),
9823 enforcement.session_netns.creations()
9824 );
9825 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
9826 assert_eq!(client.stdout.trim(), "pong");
9827 assert_eq!(enforcement.session_netns.creations(), 1);
9828
9829 enforcement.end_session();
9830 }
9831
9832 /// A foreground server demoted after its grace period keeps listening, and a later command
9833 /// reaches it; the demoted command then completes with what it received.
9834 #[test]
9835 #[allow(clippy::print_stdout)]
9836 fn a_demoted_server_stays_reachable_from_later_commands() {
9837 use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
9838
9839 if crate::network_namespace::skip_without_egress_namespace(
9840 "a_demoted_server_stays_reachable_from_later_commands",
9841 ) {
9842 return;
9843 }
9844 let temp = tempfile::tempdir().unwrap();
9845 let policy = server_policy(&[]);
9846 let enforcement = session_enforcement(&policy);
9847 let (registry, mut reports) = DetachedRegistry::new();
9848
9849 let outcome = crate::shell::run_shell(
9850 "nc",
9851 &["-l", "127.0.0.1", "8080"],
9852 &[],
9853 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9854 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9855 &policy,
9856 &enforcement,
9857 Some(DetachPolicy {
9858 grace: std::time::Duration::ZERO,
9859 registry: Arc::clone(®istry),
9860 command: "-l 127.0.0.1 8080".to_string(),
9861 context_id: "ctx_demoted_server".to_string(),
9862 provenance: None,
9863 abandoned: None,
9864 }),
9865 )
9866 .expect("the server command runs");
9867 let crate::shell::ShellOutcome::Detached(info) = outcome else {
9868 panic!("a zero grace period demotes the server, got {outcome:?}");
9869 };
9870
9871 let client = bash(
9872 "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
9873 sleep 0.1; done; echo ping >&3",
9874 temp.path(),
9875 &policy,
9876 &enforcement,
9877 )
9878 .expect("the client command runs");
9879 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
9880
9881 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
9882 let completion = loop {
9883 match reports.try_recv() {
9884 Ok(DetachedReport::Completed(completion)) => break completion,
9885 Ok(DetachedReport::Lost(_)) => {}
9886 Err(_) if std::time::Instant::now() < deadline => {
9887 std::thread::sleep(std::time::Duration::from_millis(20));
9888 }
9889 Err(error) => panic!("the demoted server never completed: {error}"),
9890 }
9891 };
9892 let log = std::fs::read_to_string(temp.path().join(&completion.output_path))
9893 .expect("the demoted command's log is written");
9894 println!(
9895 "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
9896 info.work_id,
9897 client.exit_code,
9898 completion.work_id,
9899 completion.exit_code,
9900 log.contains("ping")
9901 );
9902 assert_eq!(completion.work_id, info.work_id);
9903 assert_eq!(completion.exit_code, 0, "log: {log}");
9904 assert!(log.contains("ping"), "log: {log}");
9905
9906 enforcement.end_session();
9907 }
9908
9909 /// Two sessions each bind the same port on their own loopback, each reaches only its own
9910 /// server, and the host reaches neither.
9911 #[test]
9912 #[allow(clippy::print_stdout)]
9913 fn two_sessions_do_not_share_loopback() {
9914 if crate::network_namespace::skip_without_egress_namespace(
9915 "two_sessions_do_not_share_loopback",
9916 ) {
9917 return;
9918 }
9919 let port = {
9920 let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9921 probe.local_addr().unwrap().port()
9922 };
9923 let policy = server_policy(&[]);
9924 let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
9925 let session_a = session_enforcement(&policy);
9926 let session_b = session_enforcement(&policy);
9927
9928 for (dir, session, reply) in [
9929 (&dir_a, &session_a, "pong-a"),
9930 (&dir_b, &session_b, "pong-b"),
9931 ] {
9932 let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
9933 .expect("the server command runs");
9934 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
9935 }
9936 // Both servers bound the same port: each is still running, waiting for its one client.
9937 std::thread::sleep(std::time::Duration::from_millis(300));
9938 for dir in [&dir_a, &dir_b] {
9939 let pid = pid_in(dir.path(), "server.pid");
9940 assert!(process_exists(pid), "a server's bind on {port} failed");
9941 }
9942
9943 let host = std::net::TcpStream::connect(("127.0.0.1", port));
9944 let host_error = host.expect_err("the host must not reach a session's loopback server");
9945 assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
9946
9947 let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
9948 .expect("B's client runs");
9949 assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
9950
9951 // B's one-shot server is spent; A's still listens on the same port in A's namespace.
9952 let second_b = bash(
9953 &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
9954 dir_b.path(),
9955 &policy,
9956 &session_b,
9957 )
9958 .expect("B's second client runs");
9959
9960 let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
9961 .expect("A's client runs");
9962 println!(
9963 "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
9964 stderr {:?}; A read {:?}",
9965 client_b.stdout.trim(),
9966 second_b.exit_code,
9967 second_b.stderr.trim(),
9968 client_a.stdout.trim()
9969 );
9970 assert_eq!(client_b.stdout.trim(), "pong-b");
9971 assert_ne!(second_b.exit_code, 0);
9972 assert!(
9973 second_b.stderr.contains("Connection refused"),
9974 "stderr: {}",
9975 second_b.stderr
9976 );
9977 assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
9978 assert_eq!(client_a.stdout.trim(), "pong-a");
9979
9980 session_a.end_session();
9981 session_b.end_session();
9982 }
9983
9984 /// The session's one egress proxy serves every command, refuses what is not allowlisted,
9985 /// and is created once.
9986 #[test]
9987 #[allow(clippy::print_stdout)]
9988 fn the_session_proxy_serves_every_command() {
9989 if crate::network_namespace::skip_without_egress_namespace(
9990 "the_session_proxy_serves_every_command",
9991 ) {
9992 return;
9993 }
9994 // Answers every connection, so the first and third commands both get a reply.
9995 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9996 let port = listener.local_addr().unwrap().port();
9997 std::thread::spawn(move || {
9998 for stream in listener.incoming() {
9999 use std::io::Write;
10000 if let Ok(mut stream) = stream {
10001 let _ = stream.write_all(b"pong\n");
10002 }
10003 }
10004 });
10005
10006 let temp = tempfile::tempdir().unwrap();
10007 let policy = CapabilityPolicy {
10008 network_allow: vec![format!("http://127.0.0.1:{port}")],
10009 ..server_policy(&[])
10010 };
10011 let enforcement = session_enforcement(&policy);
10012 let through_proxy =
10013 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
10014
10015 let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
10016 let unlisted = bash(
10017 "exec 3<>/dev/tcp/10.255.255.1/9",
10018 temp.path(),
10019 &policy,
10020 &enforcement,
10021 )
10022 .unwrap();
10023 let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
10024 println!(
10025 "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
10026 created: {}",
10027 first.stdout.trim(),
10028 first.exit_code,
10029 unlisted.exit_code,
10030 unlisted.stderr.trim(),
10031 third.stdout.trim(),
10032 third.exit_code,
10033 enforcement.session_netns.creations()
10034 );
10035 assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
10036 assert_eq!(first.stdout.trim(), "pong");
10037 assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
10038 assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
10039 assert_eq!(third.stdout.trim(), "pong");
10040 assert_eq!(enforcement.session_netns.creations(), 1);
10041
10042 enforcement.end_session();
10043 }
10044
10045 /// Commands whose first spawns race each other still create exactly one namespace, and
10046 /// all of them land in it.
10047 #[test]
10048 #[allow(clippy::print_stdout)]
10049 fn concurrent_first_commands_share_one_namespace() {
10050 if crate::network_namespace::skip_without_egress_namespace(
10051 "concurrent_first_commands_share_one_namespace",
10052 ) {
10053 return;
10054 }
10055 let temp = tempfile::tempdir().unwrap();
10056 let policy = server_policy(&[]);
10057 let enforcement = session_enforcement(&policy);
10058 // One one-shot server per client, on its own port: a second client queued on a one-shot
10059 // `nc -l` would sit in its listen backlog and be closed unanswered when it exits.
10060 let servers = (8081..=8083)
10061 .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
10062 .collect::<Vec<_>>()
10063 .join("; ");
10064 let scripts = [
10065 servers,
10066 retrying_client(8081),
10067 retrying_client(8082),
10068 retrying_client(8083),
10069 ];
10070
10071 let barrier = std::sync::Barrier::new(scripts.len());
10072 let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
10073 let handles: Vec<_> = scripts
10074 .iter()
10075 .map(|script| {
10076 let enforcement = enforcement.clone();
10077 let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
10078 scope.spawn(move || {
10079 barrier.wait();
10080 bash(script, workdir, policy, &enforcement).expect("the command runs")
10081 })
10082 })
10083 .collect();
10084 handles
10085 .into_iter()
10086 .map(|handle| handle.join().unwrap())
10087 .collect()
10088 });
10089
10090 for (index, result) in results.iter().enumerate() {
10091 println!(
10092 "command {index}: exit {} stdout {:?}",
10093 result.exit_code,
10094 result.stdout.trim()
10095 );
10096 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
10097 }
10098 for client in &results[1..] {
10099 assert_eq!(client.stdout.trim(), "pong");
10100 }
10101 println!(
10102 "namespaces created: {}",
10103 enforcement.session_netns.creations()
10104 );
10105 assert_eq!(enforcement.session_netns.creations(), 1);
10106
10107 enforcement.end_session();
10108 }
10109
10110 /// Ending the session kills everything it started — a `setsid` process and a background
10111 /// server included — releases the namespace, and refuses every command after.
10112 #[test]
10113 #[allow(clippy::print_stdout)]
10114 fn ending_the_session_kills_everything_it_started() {
10115 if crate::cgroup::skip_without_host_support(
10116 "ending_the_session_kills_everything_it_started",
10117 ) {
10118 return;
10119 }
10120 let temp = tempfile::tempdir().unwrap();
10121 let policy = server_policy(&["setsid"]);
10122 let scope =
10123 crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
10124 .expect("a delegated scope")
10125 .scope
10126 .expect("a required scope on Linux is never None");
10127 let enforcement =
10128 session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
10129
10130 let started = bash(
10131 "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
10132 nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
10133 temp.path(),
10134 &policy,
10135 &enforcement,
10136 )
10137 .expect("the command runs");
10138 assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
10139 let sleeper = pid_in(temp.path(), "sleeper.pid");
10140 let server = pid_in(temp.path(), "server.pid");
10141 assert!(process_exists(sleeper), "the setsid sleeper is running");
10142 assert!(process_exists(server), "the background server is running");
10143
10144 let ending = std::time::Instant::now();
10145 enforcement.end_session();
10146 let took = ending.elapsed();
10147
10148 let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
10149 let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
10150 let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
10151 let after = bash("true", temp.path(), &policy, &enforcement);
10152 println!(
10153 "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
10154 gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
10155 took.as_millis(),
10156 procs.trim(),
10157 after.as_ref().map(|result| result.exit_code)
10158 );
10159 assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
10160 assert!(sleeper_gone, "the setsid sleeper outlived its session");
10161 assert!(server_gone, "the background server outlived its session");
10162 assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
10163 let refusal = after
10164 .expect_err("a command after the session ended must be refused")
10165 .to_string();
10166 assert!(refusal.contains("session has ended"), "{refusal}");
10167 }
10168}